Compare commits

..

No commits in common. "8ad735fbf9b52190e4ca8b0700b3f5ada4c9ed5a" and "0aae00a96bb1812f47b2f62ec250df355402bbce" have entirely different histories.

51 changed files with 1175 additions and 508 deletions

View File

@ -206,6 +206,7 @@ ### Casting — Wajib untuk Semua Tipe
'is_affiliate' => 'boolean', 'is_affiliate' => 'boolean',
// Array (JSON) // Array (JSON)
'marketplace_settings_snapshot' => 'array',
'payload' => 'array', 'payload' => 'array',
// DateTime // DateTime

View File

@ -26,7 +26,7 @@ ### `push_subscriptions` → PushSubscription
- Relations: user(MorphTo) - Relations: user(MorphTo)
### `notifications` → AppNotification ### `notifications` → AppNotification
`id` `user_id`(FK→users) `title` `body`(longText,null) `url`(null) `is_read`(bool,default:false) `read_at`(datetime,null) `created_at` `updated_at` `id` `user_id`(FK→users) `title` `body`(text,null) `url`(null) `is_read`(bool,default:false) `read_at`(datetime,null) `created_at` `updated_at`
- Casts: is_read(bool), read_at(datetime) - Casts: is_read(bool), read_at(datetime)
- Accessor: formatted_read_at → 'l, d F Y H:i' - Accessor: formatted_read_at → 'l, d F Y H:i'
- Relations: user(BelongsTo→User) - Relations: user(BelongsTo→User)
@ -167,8 +167,8 @@ ### `leave_requests` → LeaveRequest
## Sales ## Sales
### `orders` → Order ### `orders` → Order
`id` `customer_id`(FK→customers,null) `marketing_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `order_number`(30,unique) `channel`(enum) `price_type`(enum) `status`(enum,default:pending) `payment_type`(enum,default:cash) `tiktok_order_id`(100,null) `shopee_order_id`(100,null) `subtotal`(ubig) `discount`(ubig,default:0) `nego_price`(ubig,null) `total_amount`(ubig) `cogs`(ubig,default:0) `notes`(text,null) `created_at` `updated_at` `deleted_at` `id` `customer_id`(FK→customers,null) `marketing_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `order_number`(30,unique) `channel`(enum) `price_type`(enum) `status`(enum,default:pending) `payment_type`(enum,default:cash) `is_affiliate`(bool,default:false) `tiktok_order_id`(100,null) `shopee_order_id`(100,null) `subtotal`(ubig) `discount`(ubig,default:0) `nego_price`(ubig,null) `marketplace_settings_snapshot`(json,null) `total_amount`(ubig) `cogs`(ubig,default:0) `notes`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: channel(OrderChannel), price_type(PriceType), status(OrderStatus), payment_type(PaymentType), subtotal(int), discount(int), nego_price(int), total_amount(int), cogs(int) - Casts: channel(OrderChannel), price_type(PriceType), status(OrderStatus), payment_type(PaymentType), is_affiliate(bool), subtotal(int), discount(int), nego_price(int), total_amount(int), cogs(int), marketplace_settings_snapshot(array)
- Scopes: cancelled(), cash(), completed(), pending(), processing(), qris(), refunded(), retail(), shopee(), store(), tiktok(), transfer(), wholesale() - Scopes: cancelled(), cash(), completed(), pending(), processing(), qris(), refunded(), retail(), shopee(), store(), tiktok(), transfer(), wholesale()
- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User), customer(BelongsTo→Customer), marketing(BelongsTo→User), orderItems(HasMany→OrderItem) - Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User), customer(BelongsTo→Customer), marketing(BelongsTo→User), orderItems(HasMany→OrderItem)

View File

@ -4,7 +4,6 @@
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Enums\Role;
use App\Models\Employee; use App\Models\Employee;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
@ -14,7 +13,7 @@ class GeneratePayrollCommand extends Command
{ {
protected $signature = 'payroll:generate'; protected $signature = 'payroll:generate';
protected $description = 'Generate payroll for all active employees (scheduled on 5th of each month)'; protected $description = 'Generate payroll for all active employees for the current month';
public function handle(): int public function handle(): int
{ {
@ -34,7 +33,6 @@ public function handle(): int
} }
$employees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true)) $employees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))
->whereDoesntHave('user.roles', fn ($q) => $q->where('name', Role::ADMIN_BAHAN_BAKU))
->where(fn ($q) => $q->whereNull('resign_date')->orWhere('resign_date', '>=', $now->toDateString())) ->where(fn ($q) => $q->whereNull('resign_date')->orWhere('resign_date', '>=', $now->toDateString()))
->get(); ->get();

View File

@ -168,6 +168,8 @@ enum Permission: string
case SETTINGS_UPDATE_HOMEPAGE = 'settings.update_homepage'; case SETTINGS_UPDATE_HOMEPAGE = 'settings.update_homepage';
case SETTINGS_VIEW_SOCIAL_MEDIA = 'settings.view_social_media'; case SETTINGS_VIEW_SOCIAL_MEDIA = 'settings.view_social_media';
case SETTINGS_UPDATE_SOCIAL_MEDIA = 'settings.update_social_media'; case SETTINGS_UPDATE_SOCIAL_MEDIA = 'settings.update_social_media';
case SETTINGS_VIEW_MARKETPLACE = 'settings.view_marketplace';
case SETTINGS_UPDATE_MARKETPLACE = 'settings.update_marketplace';
case SETTINGS_VIEW_HR = 'settings.view_hr'; case SETTINGS_VIEW_HR = 'settings.view_hr';
case SETTINGS_UPDATE_HR = 'settings.update_hr'; case SETTINGS_UPDATE_HR = 'settings.update_hr';

View File

@ -195,6 +195,8 @@ public function permissions(): array
Permission::SETTINGS_UPDATE_SYSTEM, Permission::SETTINGS_UPDATE_SYSTEM,
Permission::SETTINGS_VIEW_SOCIAL_MEDIA, Permission::SETTINGS_VIEW_SOCIAL_MEDIA,
Permission::SETTINGS_UPDATE_SOCIAL_MEDIA, Permission::SETTINGS_UPDATE_SOCIAL_MEDIA,
Permission::SETTINGS_VIEW_MARKETPLACE,
Permission::SETTINGS_UPDATE_MARKETPLACE,
Permission::SETTINGS_VIEW_HR, Permission::SETTINGS_VIEW_HR,
Permission::SETTINGS_UPDATE_HR, Permission::SETTINGS_UPDATE_HR,
Permission::SETTINGS_VIEW_HOMEPAGE, Permission::SETTINGS_VIEW_HOMEPAGE,

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Settings\UpdateHomepageRequest; use App\Http\Requests\Admin\Settings\UpdateHomepageRequest;
use App\Http\Requests\Admin\Settings\UpdateHRRequest; use App\Http\Requests\Admin\Settings\UpdateHRRequest;
use App\Http\Requests\Admin\Settings\UpdateMarketplaceRequest;
use App\Http\Requests\Admin\Settings\UpdateSocialMediaRequest; use App\Http\Requests\Admin\Settings\UpdateSocialMediaRequest;
use App\Http\Requests\Admin\Settings\UpdateSystemRequest; use App\Http\Requests\Admin\Settings\UpdateSystemRequest;
use App\Services\Admin\AdminSettingsService; use App\Services\Admin\AdminSettingsService;
@ -24,6 +25,7 @@ public function index(): Response
'system' => $this->service->getSystemData(), 'system' => $this->service->getSystemData(),
'homepage' => $this->service->getHomepageData(), 'homepage' => $this->service->getHomepageData(),
'socialMedia' => $this->service->getSocialMediaData(), 'socialMedia' => $this->service->getSocialMediaData(),
'marketplace' => $this->service->getMarketplaceData(),
'hr' => $this->service->getHRData(), 'hr' => $this->service->getHRData(),
]); ]);
} }
@ -55,6 +57,15 @@ public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectRe
return back(); return back();
} }
public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectResponse
{
$this->service->updateMarketplace($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan marketplace berhasil diperbarui.']);
return back();
}
public function updateHR(UpdateHRRequest $request): RedirectResponse public function updateHR(UpdateHRRequest $request): RedirectResponse
{ {
$this->service->updateHR($request->validated()); $this->service->updateHR($request->validated());

View File

@ -37,6 +37,7 @@ public function rules(): array
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],
'nego_price' => ['nullable', 'integer'], 'nego_price' => ['nullable', 'integer'],
'is_completed' => ['sometimes', 'boolean'], 'is_completed' => ['sometimes', 'boolean'],
'is_affiliate' => ['sometimes', 'boolean'],
'tiktok_order_id' => ['nullable', 'string', 'max:100'], 'tiktok_order_id' => ['nullable', 'string', 'max:100'],
'shopee_order_id' => ['nullable', 'string', 'max:100'], 'shopee_order_id' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'], 'items' => ['required', 'array', 'min:1'],
@ -65,6 +66,7 @@ public function attributes(): array
'discount' => 'diskon', 'discount' => 'diskon',
'nego_price' => 'harga nego', 'nego_price' => 'harga nego',
'is_completed' => 'pesanan selesai', 'is_completed' => 'pesanan selesai',
'is_affiliate' => 'affiliasi',
'tiktok_order_id' => 'id pesanan tiktok', 'tiktok_order_id' => 'id pesanan tiktok',
'shopee_order_id' => 'id pesanan shopee', 'shopee_order_id' => 'id pesanan shopee',
'items' => 'item produk', 'items' => 'item produk',

View File

@ -0,0 +1,108 @@
<?php
namespace App\Http\Requests\Admin\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMarketplaceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('settings.update_marketplace');
}
private function feeRuleRules(): array
{
return ['required', 'array'];
}
private function feeFieldRules(string $prefix): array
{
return [
"{$prefix}.base" => ['required', 'string', 'in:per_transaksi,per_produk'],
"{$prefix}.type" => ['required', 'string', 'in:flat,persentase'],
"{$prefix}.value" => ['required', 'numeric', 'min:0'],
];
}
public function rules(): array
{
$keys = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
$rules = [];
foreach ($keys as $key) {
$rules[$key] = $this->feeRuleRules();
$rules = array_merge($rules, $this->feeFieldRules($key));
}
return $rules;
}
public function attributes(): array
{
return [
'tiktok_shop_platform_commission.base' => 'dasar komisi platform tiktok shop',
'tiktok_shop_platform_commission.type' => 'tipe komisi platform tiktok shop',
'tiktok_shop_platform_commission.value' => 'nilai komisi platform tiktok shop',
'tiktok_shop_logistics_service_fee.base' => 'dasar layanan logistik tiktok shop',
'tiktok_shop_logistics_service_fee.type' => 'tipe layanan logistik tiktok shop',
'tiktok_shop_logistics_service_fee.value' => 'nilai layanan logistik tiktok shop',
'tiktok_shop_dynamic_commission.base' => 'dasar komisi dinamis tiktok shop',
'tiktok_shop_dynamic_commission.type' => 'tipe komisi dinamis tiktok shop',
'tiktok_shop_dynamic_commission.value' => 'nilai komisi dinamis tiktok shop',
'tiktok_shop_order_processing_fee.base' => 'dasar pemrosesan pesanan tiktok shop',
'tiktok_shop_order_processing_fee.type' => 'tipe pemrosesan pesanan tiktok shop',
'tiktok_shop_order_processing_fee.value' => 'nilai pemrosesan pesanan tiktok shop',
'tiktok_shop_affiliate.base' => 'dasar affiliate tiktok shop',
'tiktok_shop_affiliate.type' => 'tipe affiliate tiktok shop',
'tiktok_shop_affiliate.value' => 'nilai affiliate tiktok shop',
'tiktok_shop_pre_order_service_fee.base' => 'dasar layanan po tiktok shop',
'tiktok_shop_pre_order_service_fee.type' => 'tipe layanan po tiktok shop',
'tiktok_shop_pre_order_service_fee.value' => 'nilai layanan po tiktok shop',
'shopee_admin_fee.base' => 'dasar biaya administrasi shopee',
'shopee_admin_fee.type' => 'tipe biaya administrasi shopee',
'shopee_admin_fee.value' => 'nilai biaya administrasi shopee',
'shopee_program_fee.base' => 'dasar biaya program shopee',
'shopee_program_fee.type' => 'tipe biaya program shopee',
'shopee_program_fee.value' => 'nilai biaya program shopee',
'shopee_shipping_savings.base' => 'dasar hemat biaya kirim shopee',
'shopee_shipping_savings.type' => 'tipe hemat biaya kirim shopee',
'shopee_shipping_savings.value' => 'nilai hemat biaya kirim shopee',
'shopee_premium.base' => 'dasar premi shopee',
'shopee_premium.type' => 'tipe premi shopee',
'shopee_premium.value' => 'nilai premi shopee',
'shopee_service_fee.base' => 'dasar biaya layanan shopee',
'shopee_service_fee.type' => 'tipe biaya layanan shopee',
'shopee_service_fee.value' => 'nilai biaya layanan shopee',
'shopee_order_processing_fee.base' => 'dasar biaya proses pesanan shopee',
'shopee_order_processing_fee.type' => 'tipe biaya proses pesanan shopee',
'shopee_order_processing_fee.value' => 'nilai biaya proses pesanan shopee',
'shopee_ams_commission_fee.base' => 'dasar biaya komisi ams shopee',
'shopee_ams_commission_fee.type' => 'tipe biaya komisi ams shopee',
'shopee_ams_commission_fee.value' => 'nilai biaya komisi ams shopee',
'shopee_pre_order.base' => 'dasar po shopee',
'shopee_pre_order.type' => 'tipe po shopee',
'shopee_pre_order.value' => 'nilai po shopee',
'shopee_live_extra.base' => 'dasar live extra shopee',
'shopee_live_extra.type' => 'tipe live extra shopee',
'shopee_live_extra.value' => 'nilai live extra shopee',
];
}
}

View File

@ -40,22 +40,11 @@ public function handle(): void
return; return;
} }
$cuttingDay = 5; $year = $yesterday->year;
$month = $yesterday->month;
if ($yesterday->day < $cuttingDay) { $period = PayrollPeriod::where('year', $year)
$periodMonth = $yesterday->month; ->where('month', $month)
$periodYear = $yesterday->year;
} else {
$periodMonth = $yesterday->month + 1;
$periodYear = $yesterday->year;
if ($periodMonth > 12) {
$periodMonth = 1;
$periodYear++;
}
}
$period = PayrollPeriod::where('year', $periodYear)
->where('month', $periodMonth)
->first(); ->first();
if (! $period) { if (! $period) {

View File

@ -1,72 +0,0 @@
<?php
namespace App\Jobs;
use App\Enums\Permission;
use App\Models\Attendance;
use App\Models\LeaveRequest;
use App\Models\User;
use App\Notifications\WebPushNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendAttendanceReminderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function handle(): void
{
$today = now()->toDateString();
$users = User::query()
->select(['id', 'name'])
->active()
->whereHas('roles', function ($q) {
$q->whereHas('permissions', fn($pq) => $pq->where('name', Permission::ATTENDANCES_CREATE->value));
})
->whereHas('employee')
->get();
foreach ($users as $user) {
$employee = $user->employee;
if (! $employee) {
continue;
}
$hasAttendedToday = Attendance::where('employee_id', $employee->id)
->where('attendance_date', $today)
->exists();
if ($hasAttendedToday) {
continue;
}
$isOnLeave = LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', $today)
->where('end_date', '>=', $today)
->exists();
if ($isOnLeave) {
continue;
}
try {
$user->notify(new WebPushNotification(
title: 'Reminder Presensi',
body: 'Selamat pagi! Jangan lupa untuk melakukan presensi masuk hari ini.',
url: route('admin.hr.attendances.index'),
));
} catch (\Exception $e) {
Log::error("Gagal mengirim reminder presensi ke user {$user->id}: {$e->getMessage()}");
}
}
}
}

View File

@ -34,11 +34,13 @@ protected function casts(): array
'price_type' => PriceType::class, 'price_type' => PriceType::class,
'status' => OrderStatus::class, 'status' => OrderStatus::class,
'payment_type' => PaymentType::class, 'payment_type' => PaymentType::class,
'is_affiliate' => 'boolean',
'subtotal' => 'integer', 'subtotal' => 'integer',
'discount' => 'integer', 'discount' => 'integer',
'nego_price' => 'integer', 'nego_price' => 'integer',
'total_amount' => 'integer', 'total_amount' => 'integer',
'cogs' => 'integer', 'cogs' => 'integer',
'marketplace_settings_snapshot' => 'array',
]; ];
} }

View File

@ -16,7 +16,6 @@ public function __construct(
public string $title, public string $title,
public string $body, public string $body,
public string $icon = '/icon-192x192.png', public string $icon = '/icon-192x192.png',
public ?string $url = null,
) {} ) {}
public function via(object $notifiable): array public function via(object $notifiable): array
@ -26,15 +25,9 @@ public function via(object $notifiable): array
public function toWebPush(object $notifiable, mixed $notification): WebPushMessage public function toWebPush(object $notifiable, mixed $notification): WebPushMessage
{ {
$webPushMessage = (new WebPushMessage) return (new WebPushMessage)
->title($this->title) ->title($this->title)
->icon($this->icon) ->icon($this->icon)
->body($this->body); ->body($this->body);
if ($this->url) {
$webPushMessage->data(['url' => $this->url]);
}
return $webPushMessage;
} }
} }

View File

@ -13,7 +13,7 @@ public function migrate(): array
{ {
$results = []; $results = [];
$results['orders'] = $this->migrateTableWithoutColumns('orders', ['marketplace_settings_snapshot', 'is_affiliate'], function ($row) { $results['orders'] = $this->migrateTable('orders', function ($row) {
$row['cogs'] = $row['cogs'] ?? 0; $row['cogs'] = $row['cogs'] ?? 0;
$row['price_type'] = match ($row['price_type']) { $row['price_type'] = match ($row['price_type']) {

View File

@ -5,8 +5,10 @@
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use App\Settings\HomepageSettings; use App\Settings\HomepageSettings;
use App\Settings\HRSettings; use App\Settings\HRSettings;
use App\Settings\MarketplaceSettings;
use App\Settings\SocialMediaSettings; use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings; use App\Settings\SystemSettings;
use App\Support\Marketplace\MarketplaceFeeRule;
class AdminSettingsService class AdminSettingsService
{ {
@ -55,6 +57,33 @@ public function getSocialMediaData(): array
]; ];
} }
public function getMarketplaceData(): array
{
$settings = app(MarketplaceSettings::class);
return [
'tiktok_shop' => [
'platform_commission' => $settings->tiktok_shop_platform_commission->toArray(),
'logistics_service_fee' => $settings->tiktok_shop_logistics_service_fee->toArray(),
'dynamic_commission' => $settings->tiktok_shop_dynamic_commission->toArray(),
'order_processing_fee' => $settings->tiktok_shop_order_processing_fee->toArray(),
'affiliate' => $settings->tiktok_shop_affiliate->toArray(),
'pre_order_service_fee' => $settings->tiktok_shop_pre_order_service_fee->toArray(),
],
'shopee' => [
'admin_fee' => $settings->shopee_admin_fee->toArray(),
'program_fee' => $settings->shopee_program_fee->toArray(),
'shipping_savings' => $settings->shopee_shipping_savings->toArray(),
'premium' => $settings->shopee_premium->toArray(),
'service_fee' => $settings->shopee_service_fee->toArray(),
'order_processing_fee' => $settings->shopee_order_processing_fee->toArray(),
'ams_commission_fee' => $settings->shopee_ams_commission_fee->toArray(),
'pre_order' => $settings->shopee_pre_order->toArray(),
'live_extra' => $settings->shopee_live_extra->toArray(),
],
];
}
public function getHRData(): array public function getHRData(): array
{ {
$settings = app(HRSettings::class); $settings = app(HRSettings::class);
@ -92,6 +121,37 @@ public function updateSocialMedia(array $data): void
$settings->save(); $settings->save();
} }
public function updateMarketplace(array $data): void
{
$settings = app(MarketplaceSettings::class);
$feeKeys = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
foreach ($feeKeys as $key) {
if (isset($data[$key])) {
$settings->{$key} = MarketplaceFeeRule::from($data[$key]);
}
}
$settings->save();
}
public function updateHR(array $data): void public function updateHR(array $data): void
{ {
$settings = app(HRSettings::class); $settings = app(HRSettings::class);

View File

@ -47,22 +47,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
public function getCurrentOrCreate(): PayrollPeriod public function getCurrentOrCreate(): PayrollPeriod
{ {
$now = now(); $now = now();
$cuttingDay = 5;
if ($now->day < $cuttingDay) {
$year = $now->year;
$month = $now->month;
} else {
$year = $now->year;
$month = $now->month + 1;
if ($month > 12) {
$month = 1;
$year++;
}
}
return PayrollPeriod::firstOrCreate( return PayrollPeriod::firstOrCreate(
['year' => $year, 'month' => $month], ['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN] ['status' => PayrollPeriodStatus::OPEN]
); );
} }

View File

@ -189,6 +189,7 @@ public function store(array $data): Order
'price_type' => $priceType, 'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING, 'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $paymentType, 'payment_type' => $paymentType,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null, 'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null, 'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal, 'subtotal' => $subtotal,
@ -264,6 +265,7 @@ public function update(Order $order, array $data): Order
'price_type' => $priceType, 'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING, 'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $data['payment_type'] ?? $order->payment_type->value, 'payment_type' => $data['payment_type'] ?? $order->payment_type->value,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null, 'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null, 'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal, 'subtotal' => $subtotal,
@ -384,8 +386,7 @@ private function generateOrderNumber(): string
{ {
$prefix = 'TRX'; $prefix = 'TRX';
$date = now()->format('ymd'); $date = now()->format('ymd');
$lastOrder = Order::withTrashed() $lastOrder = Order::where('order_number', 'like', "{$prefix}{$date}%")
->where('order_number', 'like', "{$prefix}{$date}%")
->orderByDesc('order_number') ->orderByDesc('order_number')
->first(); ->first();

View File

@ -448,18 +448,12 @@ public function getExpenseSummary(?string $startDate, ?string $endDate, ?User $u
$advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID); $advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at'); $this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
$expenseTotal = (clone $expenseQuery)->sum('amount');
$advanceTotal = (clone $advanceQuery)->sum('amount');
$includePurchase = $user && $this->isPurchaseVisible($user);
if ($includePurchase) {
$purchaseQuery = Purchase::query(); $purchaseQuery = Purchase::query();
$this->applyDateFilter($purchaseQuery, $startDate, $endDate, 'purchases.created_at'); $this->applyDateFilter($purchaseQuery, $startDate, $endDate, 'purchases.created_at');
$expenseTotal = (clone $expenseQuery)->sum('amount');
$advanceTotal = (clone $advanceQuery)->sum('amount');
$purchaseTotal = (clone $purchaseQuery)->sum('total'); $purchaseTotal = (clone $purchaseQuery)->sum('total');
} else {
$purchaseTotal = 0;
}
return [ return [
'total' => (int) ($expenseTotal + $advanceTotal + $purchaseTotal), 'total' => (int) ($expenseTotal + $advanceTotal + $purchaseTotal),
@ -527,19 +521,6 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate, ?User $u
->get() ->get()
->keyBy('month'); ->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$includePurchase = $user && $this->isPurchaseVisible($user);
if ($includePurchase) {
$purchaseMonthly = Purchase::query(); $purchaseMonthly = Purchase::query();
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate, 'purchases.created_at'); $this->applyDateFilter($purchaseMonthly, $startDate, $endDate, 'purchases.created_at');
@ -549,9 +530,16 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate, ?User $u
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')")) ->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get() ->get()
->keyBy('month'); ->keyBy('month');
} else {
$purchaseByMonth = collect(); $advanceMonthly = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
} $this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = []; $allMonths = [];
foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) { foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) {
@ -566,7 +554,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate, ?User $u
$row['purchase'] = (int) ($purchaseByMonth[$month]->purchase ?? 0); $row['purchase'] = (int) ($purchaseByMonth[$month]->purchase ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0); $row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0); $row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0);
$row['total'] = $row['expense'] + $row['advance'] + $row['purchase']; $row['total'] = $row['purchase'] + $row['expense'] + $row['advance'];
} }
return array_values($allMonths); return array_values($allMonths);
@ -855,15 +843,6 @@ private function isMarketingUser(User $user): bool
]); ]);
} }
private function isPurchaseVisible(User $user): bool
{
return $user->hasAnyRole([
Role::DEVELOPER->value,
Role::OWNER->value,
Role::ADMIN_BAHAN_BAKU->value,
]);
}
private function applyMarketingFilter(Builder $query, User $user, string $column = 'marketing_id'): Builder private function applyMarketingFilter(Builder $query, User $user, string $column = 'marketing_id'): Builder
{ {
if ($this->isMarketingUser($user)) { if ($this->isMarketingUser($user)) {

View File

@ -0,0 +1,44 @@
<?php
namespace App\Settings;
use App\Support\Marketplace\MarketplaceFeeRule;
use Spatie\LaravelSettings\Settings;
class MarketplaceSettings extends Settings
{
public MarketplaceFeeRule $tiktok_shop_platform_commission;
public MarketplaceFeeRule $tiktok_shop_logistics_service_fee;
public MarketplaceFeeRule $tiktok_shop_dynamic_commission;
public MarketplaceFeeRule $tiktok_shop_order_processing_fee;
public MarketplaceFeeRule $tiktok_shop_affiliate;
public MarketplaceFeeRule $tiktok_shop_pre_order_service_fee;
public MarketplaceFeeRule $shopee_admin_fee;
public MarketplaceFeeRule $shopee_program_fee;
public MarketplaceFeeRule $shopee_shipping_savings;
public MarketplaceFeeRule $shopee_premium;
public MarketplaceFeeRule $shopee_service_fee;
public MarketplaceFeeRule $shopee_order_processing_fee;
public MarketplaceFeeRule $shopee_ams_commission_fee;
public MarketplaceFeeRule $shopee_pre_order;
public MarketplaceFeeRule $shopee_live_extra;
public static function group(): string
{
return 'marketplace';
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Support\Marketplace;
use Spatie\LaravelData\Data;
class MarketplaceFeeRule extends Data
{
public function __construct(
public string $base = 'per_transaksi',
public string $type = 'persentase',
public float $value = 0.0,
) {}
public static function defaultPercent(float $value): static
{
return new static(
base: 'per_transaksi',
type: 'persentase',
value: $value,
);
}
public function toArray(): array
{
return [
'base' => $this->base,
'type' => $this->type,
'value' => (float) $this->value,
];
}
}

View File

@ -21,6 +21,7 @@ public function definition(): array
'price_type' => fake()->randomElement(['retail', 'wholesale']), 'price_type' => fake()->randomElement(['retail', 'wholesale']),
'status' => 'pending', 'status' => 'pending',
'payment_type' => fake()->randomElement(['cash', 'transfer', 'marketplace', 'qris']), 'payment_type' => fake()->randomElement(['cash', 'transfer', 'marketplace', 'qris']),
'is_affiliate' => false,
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'discount' => $discount, 'discount' => $discount,
'total_amount' => $subtotal - $discount, 'total_amount' => $subtotal - $discount,

View File

@ -25,11 +25,13 @@ public function up(): void
$table->enum('price_type', PriceType::values()); $table->enum('price_type', PriceType::values());
$table->enum('status', OrderStatus::values())->default(OrderStatus::PENDING->value); $table->enum('status', OrderStatus::values())->default(OrderStatus::PENDING->value);
$table->enum('payment_type', PaymentType::values())->default(PaymentType::CASH->value); $table->enum('payment_type', PaymentType::values())->default(PaymentType::CASH->value);
$table->boolean('is_affiliate')->default(false);
$table->string('tiktok_order_id', 100)->nullable(); $table->string('tiktok_order_id', 100)->nullable();
$table->string('shopee_order_id', 100)->nullable(); $table->string('shopee_order_id', 100)->nullable();
$table->unsignedBigInteger('subtotal'); $table->unsignedBigInteger('subtotal');
$table->unsignedBigInteger('discount')->default(0); $table->unsignedBigInteger('discount')->default(0);
$table->unsignedBigInteger('nego_price')->nullable(); $table->unsignedBigInteger('nego_price')->nullable();
$table->json('marketplace_settings_snapshot')->nullable();
$table->unsignedBigInteger('total_amount'); $table->unsignedBigInteger('total_amount');
$table->unsignedBigInteger('cogs')->default(0); $table->unsignedBigInteger('cogs')->default(0);
$table->text('notes')->nullable(); $table->text('notes')->nullable();

View File

@ -14,7 +14,7 @@ public function up(): void
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title'); $table->string('title');
$table->longText('body')->nullable(); $table->text('body')->nullable();
$table->string('url')->nullable(); $table->string('url')->nullable();
$table->boolean('is_read')->default(false); $table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable(); $table->timestamp('read_at')->nullable();

View File

@ -36,7 +36,7 @@ public function run(): void
'purchases' => ['view', 'create', 'update', 'delete'], 'purchases' => ['view', 'create', 'update', 'delete'],
'stok_opnames' => ['view', 'create', 'update', 'delete', 'submit', 'verify'], 'stok_opnames' => ['view', 'create', 'update', 'delete', 'submit', 'verify'],
'roles' => ['view', 'create', 'update', 'delete'], 'roles' => ['view', 'create', 'update', 'delete'],
'settings' => ['view_system', 'update_system', 'view_homepage', 'update_homepage', 'view_social_media', 'update_social_media', 'view_hr', 'update_hr'], 'settings' => ['view_system', 'update_system', 'view_homepage', 'update_homepage', 'view_social_media', 'update_social_media', 'view_marketplace', 'update_marketplace', 'view_hr', 'update_hr'],
]; ];
foreach ($permissions as $module => $actions) { foreach ($permissions as $module => $actions) {
@ -156,7 +156,6 @@ public function run(): void
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay', 'employee_advances.pay',
'employee_advances.view_payments', 'employee_advances.view_payments',
'employee_advances.verify',
'payroll.view', 'payroll.view',
'payroll.adjust', 'payroll.adjust',
@ -167,6 +166,8 @@ public function run(): void
'settings.update_system', 'settings.update_system',
'settings.view_social_media', 'settings.view_social_media',
'settings.update_social_media', 'settings.update_social_media',
'settings.view_marketplace',
'settings.update_marketplace',
'settings.view_hr', 'settings.view_hr',
'settings.update_hr', 'settings.update_hr',
'settings.view_homepage', 'settings.view_homepage',
@ -223,6 +224,7 @@ public function run(): void
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'employee_advances.view_payments', 'employee_advances.view_payments',
'payroll.view', 'payroll.view',
@ -259,12 +261,16 @@ public function run(): void
'cuttings.update', 'cuttings.update',
'cuttings.delete', 'cuttings.delete',
'cuttings.complete', 'cuttings.complete',
'purchases.view', 'purchases.view',
'purchases.create', 'purchases.create',
'purchases.update', 'purchases.update',
'purchases.delete', 'purchases.delete',
'employee_advances.view',
'employee_advances.create',
'employee_advances.update',
'employee_advances.delete',
'activity_logs.view', 'activity_logs.view',
'payroll.view', 'payroll.view',
@ -303,6 +309,7 @@ public function run(): void
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'employee_advances.view_payments', 'employee_advances.view_payments',
'payroll.view', 'payroll.view',
@ -341,6 +348,7 @@ public function run(): void
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'employee_advances.view_payments', 'employee_advances.view_payments',
'payroll.view', 'payroll.view',
@ -404,9 +412,6 @@ public function run(): void
'employee_advances.create', 'employee_advances.create',
'employee_advances.update', 'employee_advances.update',
'employee_advances.delete', 'employee_advances.delete',
'employee_advances.pay',
'employee_advances.view_payments',
'employee_advances.verify',
'payroll.view', 'payroll.view',
], true); ], true);

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Support\Marketplace\MarketplaceFeeRule;
use Spatie\LaravelSettings\Migrations\SettingsBlueprint; use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
use Spatie\LaravelSettings\Migrations\SettingsMigration; use Spatie\LaravelSettings\Migrations\SettingsMigration;
@ -21,6 +22,27 @@ public function up(): void
$blueprint->add('tiktok_url', null); $blueprint->add('tiktok_url', null);
}); });
$this->migrator->inGroup('marketplace', function (SettingsBlueprint $blueprint): void {
// TikTok Shop
$blueprint->add('tiktok_shop_platform_commission', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_logistics_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_dynamic_commission', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_order_processing_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_affiliate', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('tiktok_shop_pre_order_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
// Shopee
$blueprint->add('shopee_admin_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_program_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_shipping_savings', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_premium', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_service_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_order_processing_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_ams_commission_fee', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_pre_order', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
$blueprint->add('shopee_live_extra', MarketplaceFeeRule::defaultPercent(0.0)->toArray());
});
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { $this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
$blueprint->add('scheduled_check_in_time', '08:00'); $blueprint->add('scheduled_check_in_time', '08:00');
$blueprint->add('scheduled_check_out_time', '17:00'); $blueprint->add('scheduled_check_out_time', '17:00');

View File

@ -85,12 +85,12 @@ :root {
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.65 0.15 250); --chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.72 0.17 145); --chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.75 0.18 70); --chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.60 0.18 300); --chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.65 0.20 25); --chart-5: oklch(0.473 0.137 46.201);
--chart-6: oklch(0.75 0.12 195); --chart-6: oklch(0.696 0.17 162.48);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
@ -122,12 +122,12 @@ .dark {
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.70 0.15 250); --chart-1: oklch(0.879 0.169 91.605);
--chart-2: oklch(0.77 0.17 145); --chart-2: oklch(0.769 0.188 70.08);
--chart-3: oklch(0.80 0.18 70); --chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.65 0.18 300); --chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.70 0.20 25); --chart-5: oklch(0.473 0.137 46.201);
--chart-6: oklch(0.80 0.12 195); --chart-6: oklch(0.696 0.17 162.48);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.769 0.188 70.08); --sidebar-primary: oklch(0.769 0.188 70.08);

View File

@ -50,7 +50,7 @@ createInertiaApp({
); );
}, },
progress: { progress: {
color: '#d97706', color: '#4B5563',
}, },
}); });

View File

@ -1,5 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { id as idLocale } from 'react-day-picker/locale'; import { id } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react'; import { CalendarIcon } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
@ -39,17 +39,12 @@ function DatePicker({
const date = React.useMemo(() => { const date = React.useMemo(() => {
if (!value) { if (!value) {
return undefined; return undefined;
} }
if (value instanceof Date) { if (value instanceof Date) {
return value; return value;
} }
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
}
return new Date(value); return new Date(value);
}, [value]); }, [value]);
@ -59,7 +54,7 @@ function DatePicker({
return ''; return '';
} }
return format(date, 'dd MMM yyyy', { locale: idLocale }); return format(date, 'dd MMM yyyy', { locale: id });
}, [date]); }, [date]);
return ( return (
@ -82,7 +77,6 @@ return '';
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<Calendar <Calendar
mode="single" mode="single"
locale={idLocale}
selected={date} selected={date}
onSelect={(selectedDate) => { onSelect={(selectedDate) => {
onChange?.(selectedDate); onChange?.(selectedDate);

View File

@ -103,7 +103,7 @@ const hrItems: NavMenuItem[] = [
]; ];
const sistemItems: NavMenuItem[] = [ const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_hr'] }, { title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_marketplace', 'settings.view_hr'] },
{ title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' }, { title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' },
// { title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' }, // { title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' },
]; ];

View File

@ -38,8 +38,8 @@ function Calendar({
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
formatters={{ formatters={{
formatMonthDropdown: (date, options) => formatMonthDropdown: (date) =>
date.toLocaleString(options?.locale?.code ?? "id", { month: "short" }), date.toLocaleString("default", { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{

View File

@ -18,14 +18,3 @@ export function formatCurrency(amount: number): string {
minimumFractionDigits: 0, minimumFractionDigits: 0,
}).format(amount); }).format(amount);
} }
export function generateRandomColors(count: number): string[] {
const colors: string[] = [];
for (let i = 0; i < count; i++) {
const hue = Math.floor(Math.random() * 360);
const saturation = 65 + Math.floor(Math.random() * 20);
const lightness = 45 + Math.floor(Math.random() * 20);
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
}
return colors;
}

View File

@ -12,9 +12,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { generateRandomColors } from '@/lib/utils';
import { formatRupiah } from '@/lib/rupiah'; import { formatRupiah } from '@/lib/rupiah';
import { format } from 'date-fns';
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { import {
Banknote, Banknote,
@ -200,59 +198,85 @@ type AnalysisProps = {
}>; }>;
}; };
const revenueChartConfig = (() => { const revenueChartConfig = {
const colors = generateRandomColors(6); total: {
return { label: 'Total',
total: { label: 'Total', color: colors[0] }, color: 'var(--chart-1)',
gross: { label: 'Keuntungan Kotor', color: colors[1] }, },
net: { label: 'Keuntungan Bersih', color: colors[2] }, gross: {
deduction: { label: 'Potongan Nego', color: colors[3] }, label: 'Keuntungan Kotor',
discount: { label: 'Diskon', color: colors[4] }, color: 'var(--chart-2)',
cogs: { label: 'HPP', color: colors[5] }, },
} satisfies ChartConfig; net: {
})(); label: 'Keuntungan Bersih',
color: 'var(--chart-6)',
},
deduction: {
label: 'Potongan Nego',
color: 'var(--chart-3)',
},
discount: {
label: 'Diskon',
color: 'var(--chart-4)',
},
cogs: {
label: 'HPP',
color: 'var(--chart-5)',
},
} satisfies ChartConfig;
const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const; const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const;
const expenseChartConfig = (() => { const expenseChartConfig = {
const colors = generateRandomColors(4); total: {
return { label: 'Total',
total: { label: 'Total', color: colors[0] }, color: 'var(--chart-1)',
purchase: { label: 'Belanja', color: colors[1] }, },
expense: { label: 'Pengeluaran Toko', color: colors[2] }, purchase: {
advance: { label: 'Kasbon', color: colors[3] }, label: 'Belanja',
} satisfies ChartConfig; color: 'var(--chart-2)',
})(); },
expense: {
label: 'Pengeluaran Toko',
color: 'var(--chart-3)',
},
advance: {
label: 'Kasbon',
color: 'var(--chart-4)',
},
} satisfies ChartConfig;
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const; const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const;
const revenueTrendChartConfig = (() => { const revenueTrendChartConfig = {
const colors = generateRandomColors(1); qty: {
return { label: 'Qty',
qty: { label: 'Qty', color: colors[0] }, color: 'var(--chart-1)',
} satisfies ChartConfig; },
})(); } satisfies ChartConfig;
const revenueTrendKeys = ['qty'] as const; const revenueTrendKeys = ['qty'] as const;
const CHANNEL_COLORS: Record<string, string> = (() => { const CHANNEL_COLORS: Record<string, string> = {
const colors = generateRandomColors(3); store: '#22c55e',
return { shopee: '#ee4d2d',
store: colors[0], tiktok: '#000000',
shopee: colors[1], };
tiktok: colors[2],
};
})();
const PAYMENT_COLORS: Record<string, string> = (() => { const PAYMENT_COLORS: Record<string, string> = {
const colors = generateRandomColors(4); cash: '#22c55e',
return { transfer: '#60a5fa',
cash: colors[0], qris: '#a855f7',
transfer: colors[1], marketplace: '#f97316',
qris: colors[2], };
marketplace: colors[3],
}; const PIE_COLORS = [
})(); 'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
];
type PieChartItem = { type PieChartItem = {
name: string; name: string;
@ -269,25 +293,23 @@ type DashboardPieChartProps = {
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) { function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0); const hasData = data.length > 0 && data.some((d) => d.count > 0);
const pieColors = useMemo(() => generateRandomColors(5), []);
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
const config: ChartConfig = {}; const config: ChartConfig = {};
data.forEach((item, index) => { data.forEach((item, index) => {
config[item.name] = { config[item.name] = {
label: item.name, label: item.name,
color: pieColors[index % pieColors.length], color: PIE_COLORS[index % PIE_COLORS.length],
}; };
}); });
return config; return config;
}, [data, pieColors]); }, [data]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
return data.map((item) => ({ return data.map((item) => ({
...item, ...item,
fill: pieColors[data.indexOf(item) % pieColors.length], fill: PIE_COLORS[data.indexOf(item) % PIE_COLORS.length],
})); }));
}, [data, pieColors]); }, [data]);
return ( return (
<Card> <Card>
@ -416,7 +438,7 @@ export default function Analysis({
const hasActiveFilters = !!startDate || !!endDate; const hasActiveFilters = !!startDate || !!endDate;
const formatDate = useCallback((date: Date): string => format(date, 'yyyy-MM-dd'), []); const formatDate = useCallback((date: Date): string => date.toISOString().split('T')[0], []);
const applyFilters = useCallback(() => { const applyFilters = useCallback(() => {
router.get( router.get(
@ -933,8 +955,7 @@ export default function Analysis({
tickMargin={8} tickMargin={8}
minTickGap={32} minTickGap={32}
tickFormatter={(value) => { tickFormatter={(value) => {
const [year, month, day] = String(value).split('-').map(Number); const date = new Date(value);
const date = new Date(year, month - 1, day);
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }); return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
}} }}
/> />
@ -945,9 +966,7 @@ export default function Analysis({
className="w-[150px]" className="w-[150px]"
nameKey="views" nameKey="views"
labelFormatter={(value) => { labelFormatter={(value) => {
const [year, month, day] = String(value).split('-').map(Number); return new Date(value).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
const date = new Date(year, month - 1, day);
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
}} }}
formatter={(value, name, item, index) => ( formatter={(value, name, item, index) => (
<> <>
@ -1023,7 +1042,7 @@ export default function Analysis({
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6"> <CardContent className="px-2 sm:p-6">
{topSuppliers.length > 0 ? ( {topSuppliers.length > 0 ? (
<ChartContainer config={{ amount: { label: 'Total Pembelian', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full"> <ChartContainer config={{ amount: { label: 'Total Pembelian', color: 'var(--chart-1)' } }} className="aspect-auto h-[250px] w-full">
<BarChart data={topSuppliers.map((s) => ({ name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name, amount: s.total_amount }))} margin={{ left: 12, right: 12 }}> <BarChart data={topSuppliers.map((s) => ({ name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name, amount: s.total_amount }))} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@ -1065,7 +1084,7 @@ export default function Analysis({
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6"> <CardContent className="px-2 sm:p-6">
{topProducts.length > 0 ? ( {topProducts.length > 0 ? (
<ChartContainer config={{ qty: { label: 'Jumlah Terjual', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full"> <ChartContainer config={{ qty: { label: 'Jumlah Terjual', color: 'var(--chart-3)' } }} className="aspect-auto h-[250px] w-full">
<BarChart data={topProducts.map((p) => ({ name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name, qty: p.total_qty }))} margin={{ left: 12, right: 12 }}> <BarChart data={topProducts.map((p) => ({ name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name, qty: p.total_qty }))} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@ -1107,7 +1126,7 @@ export default function Analysis({
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6"> <CardContent className="px-2 sm:p-6">
{topCustomers.length > 0 ? ( {topCustomers.length > 0 ? (
<ChartContainer config={{ amount: { label: 'Total Pesanan', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full"> <ChartContainer config={{ amount: { label: 'Total Pesanan', color: 'var(--chart-2)' } }} className="aspect-auto h-[250px] w-full">
<BarChart data={topCustomers.map((c) => ({ name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, amount: c.total_amount }))} margin={{ left: 12, right: 12 }}> <BarChart data={topCustomers.map((c) => ({ name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, amount: c.total_amount }))} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@ -1161,7 +1180,7 @@ export default function Analysis({
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6"> <CardContent className="px-2 sm:p-6">
{busyHours.length > 0 ? ( {busyHours.length > 0 ? (
<ChartContainer config={{ orders: { label: 'Pesanan', color: generateRandomColors(1)[0] } }} className="aspect-auto h-[250px] w-full"> <ChartContainer config={{ orders: { label: 'Pesanan', color: 'var(--chart-1)' } }} className="aspect-auto h-[250px] w-full">
<BarChart data={busyHours} margin={{ left: 12, right: 12 }}> <BarChart data={busyHours} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis dataKey="hour" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="hour" tickLine={false} axisLine={false} tickMargin={8} />

View File

@ -28,7 +28,6 @@ export type Payroll = {
export type PayrollAdjustment = { export type PayrollAdjustment = {
id: number; id: number;
created_by_id: number;
type: 'bonus' | 'deduction'; type: 'bonus' | 'deduction';
amount: number; amount: number;
description: string; description: string;
@ -69,8 +68,6 @@ type CreateColumnsParams = {
payrollId: number, payrollId: number,
) => void; ) => void;
can: (permission: string) => boolean; can: (permission: string) => boolean;
hasAnyRole: (roles: string[]) => boolean;
userId: number;
}; };
export function createPayrollColumns( export function createPayrollColumns(
@ -82,8 +79,6 @@ export function createPayrollColumns(
handleAddAdjustment, handleAddAdjustment,
handleDeleteAdjustment, handleDeleteAdjustment,
can, can,
hasAnyRole,
userId,
} = params; } = params;
const hasAnyPayrollAction = can('payroll.adjust') || can('payroll.pay') || can('payroll.cancel'); const hasAnyPayrollAction = can('payroll.adjust') || can('payroll.pay') || can('payroll.cancel');
@ -192,7 +187,7 @@ export function createPayrollColumns(
<span className="max-w-[100px] text-muted-foreground"> <span className="max-w-[100px] text-muted-foreground">
{adj.description} {adj.description}
</span> </span>
{can('payroll.adjust') && payroll.status === 'unpaid' && (adj.created_by_id === userId || hasAnyRole(['developer', 'owner'])) && ( {can('payroll.adjust') && payroll.status === 'unpaid' && (
<button <button
onClick={() => onClick={() =>
handleDeleteAdjustment( handleDeleteAdjustment(

View File

@ -38,14 +38,9 @@ type Props = {
status: string; status: string;
payrolls: Payroll[]; payrolls: Payroll[];
}; };
auth: {
user: {
id: number;
};
};
}; };
export default function PayrollPeriodShow({ payrollPeriod, auth }: Props) { export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const { can, hasAnyRole } = useCan(); const { can, hasAnyRole } = useCan();
const canViewAll = hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']); const canViewAll = hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
const [paying, setPaying] = useState<Payroll | null>(null); const [paying, setPaying] = useState<Payroll | null>(null);
@ -108,8 +103,6 @@ export default function PayrollPeriodShow({ payrollPeriod, auth }: Props) {
setDeletingAdjustment({ adjustment, payrollId }); setDeletingAdjustment({ adjustment, payrollId });
}, },
can, can,
hasAnyRole,
userId: auth.user.id,
}); });
const activePayrolls = payrollPeriod.payrolls.filter( const activePayrolls = payrollPeriod.payrolls.filter(

View File

@ -470,8 +470,7 @@ export default function AttendanceIndex({
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0); todayMidnight.setHours(0, 0, 0, 0);
const [cYear, cMonth, cDay] = String(cell.date).split('-').map(Number); const cellDate = new Date(cell.date);
const cellDate = new Date(cYear, cMonth - 1, cDay);
cellDate.setHours(0, 0, 0, 0); cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < todayMidnight; const isPastDate = cellDate < todayMidnight;

View File

@ -1,7 +1,7 @@
'use no memo'; 'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
@ -76,7 +76,6 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return []; return [];
}); });
const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? ''); const [selectedMaterialName, setSelectedMaterialName] = useState(draft?.selectedMaterialName ?? '');
const [variantSearch, setVariantSearch] = useState('');
const [productName, setProductName] = useState(draft?.productName ?? ''); const [productName, setProductName] = useState(draft?.productName ?? '');
const [sample, setSample] = useState(draft?.sample ?? 0); const [sample, setSample] = useState(draft?.sample ?? 0);
@ -150,22 +149,13 @@ export default function CuttingCreate({ rawMaterials }: Props) {
[rawMaterials, selectedMaterialName], [rawMaterials, selectedMaterialName],
); );
const groupedVariants = useMemo(() => {
if (!variantSearch) return [];
const search = variantSearch.toLowerCase();
return rawMaterials
.map((rm) => ({
...rm,
raw_material_prices: rm.raw_material_prices.filter(
(p) => p.variant.toLowerCase().includes(search),
),
}))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const addVariant = useCallback( const addVariant = useCallback(
(rawMaterial: (typeof rawMaterials)[number], priceId: number) => { (priceId: number) => {
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId); if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) { if (!price) {
return; return;
@ -184,14 +174,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
material_result: 0, material_result: 0,
combination_id: null, combination_id: null,
variant: price.variant, variant: price.variant,
material_name: rawMaterial.name, material_name: selectedMaterial.name,
unit: rawMaterial.unit, unit: selectedMaterial.unit,
photo_url: price.photo_url, photo_url: price.photo_url,
}, },
]; ];
}); });
}, },
[], [selectedMaterial],
); );
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => { const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
@ -337,7 +327,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<CardHeader> <CardHeader>
<CardTitle>Pilih Bahan Baku</CardTitle> <CardTitle>Pilih Bahan Baku</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-y-auto"> <CardContent className="space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Nama Bahan Baku <span className="text-destructive">*</span> Nama Bahan Baku <span className="text-destructive">*</span>
@ -362,77 +352,16 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</Combobox> </Combobox>
</div> </div>
<div className="grid gap-2"> {selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<Label className="text-sm font-medium">Cari Varian</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ketik nama varian..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
{variantSearch && groupedVariants.length > 0 && (
<div className="space-y-4">
{groupedVariants.map((rm) => (
<div key={rm.id} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
<div className="space-y-2">
{rm.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {rm.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(rm, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(rm.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{variantSearch && groupedVariants.length === 0 && (
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)}
{!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2"> <div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{price.photo_url ? ( {price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" /> <img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
@ -447,8 +376,8 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}> <Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</Button> </Button>

View File

@ -1,7 +1,7 @@
'use no memo'; 'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Check, Layers, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; import { ArrowLeft, Check, Layers, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs'; import { ConfirmDialog } from '@/components/dialogs';
@ -86,7 +86,6 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
); );
const [selectedMaterialName, setSelectedMaterialName] = useState(''); const [selectedMaterialName, setSelectedMaterialName] = useState('');
const [variantSearch, setVariantSearch] = useState('');
const [productName, setProductName] = useState(cutting.product_name); const [productName, setProductName] = useState(cutting.product_name);
const [sample, setSample] = useState(cutting.sample); const [sample, setSample] = useState(cutting.sample);
@ -131,22 +130,13 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
[rawMaterials, selectedMaterialName], [rawMaterials, selectedMaterialName],
); );
const groupedVariants = useMemo(() => {
if (!variantSearch) return [];
const search = variantSearch.toLowerCase();
return rawMaterials
.map((rm) => ({
...rm,
raw_material_prices: rm.raw_material_prices.filter(
(p) => p.variant.toLowerCase().includes(search),
),
}))
.filter((rm) => rm.raw_material_prices.length > 0);
}, [rawMaterials, variantSearch]);
const addVariant = useCallback( const addVariant = useCallback(
(rawMaterial: (typeof rawMaterials)[number], priceId: number) => { (priceId: number) => {
const price = rawMaterial.raw_material_prices.find((p) => p.id === priceId); if (!selectedMaterial) {
return;
}
const price = selectedMaterial.raw_material_prices.find((p) => p.id === priceId);
if (!price) { if (!price) {
return; return;
@ -165,14 +155,14 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
material_result: 0, material_result: 0,
combination_id: null, combination_id: null,
variant: price.variant, variant: price.variant,
material_name: rawMaterial.name, material_name: selectedMaterial.name,
unit: rawMaterial.unit, unit: selectedMaterial.unit,
photo_url: price.photo_url, photo_url: price.photo_url,
}, },
]; ];
}); });
}, },
[], [selectedMaterial],
); );
const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => { const openComboDialog = useCallback((materialName: string, preSelectPriceId?: number) => {
@ -313,7 +303,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<CardHeader> <CardHeader>
<CardTitle>Pilih Bahan Baku</CardTitle> <CardTitle>Pilih Bahan Baku</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="max-h-[calc(100vh-16rem)] space-y-4 overflow-y-auto"> <CardContent className="space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Nama Bahan Baku <span className="text-destructive">*</span> Nama Bahan Baku <span className="text-destructive">*</span>
@ -338,77 +328,16 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</Combobox> </Combobox>
</div> </div>
<div className="grid gap-2"> {selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<Label className="text-sm font-medium">Cari Varian</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ketik nama varian..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
{variantSearch && groupedVariants.length > 0 && (
<div className="space-y-4">
{groupedVariants.map((rm) => (
<div key={rm.id} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase">{rm.name} ({rm.unit})</p>
<div className="space-y-2">
{rm.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">{price.variant}</p>
<p className="text-xs text-muted-foreground">
Stok: {formatNumber(Number(price.stock))} {rm.unit} · {formatCurrency(price.price)}
{addedCount > 0 && ` · ×${addedCount}`}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(rm, price.id)}>
<Plus className="h-4 w-4" />
Tambah
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => openComboDialog(rm.name, price.id)}>
<Layers className="h-4 w-4" />
Kombinasi
</Button>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{variantSearch && groupedVariants.length === 0 && (
<p className="text-sm text-muted-foreground">Tidak ada varian ditemukan.</p>
)}
{!variantSearch && selectedMaterial && selectedMaterial.raw_material_prices.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Pilih Varian</Label>
<div className="space-y-2"> <div className="space-y-2">
{selectedMaterial.raw_material_prices.map((price) => { {selectedMaterial.raw_material_prices.map((price) => {
const isAdded = materials.some((m) => m.raw_material_price_id === price.id); const isAdded = materials.some((m) => m.raw_material_price_id === price.id);
const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length; const addedCount = materials.filter((m) => m.raw_material_price_id === price.id).length;
return ( return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}> <div key={price.id} className={isAdded ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'}>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
{price.photo_url ? ( {price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" /> <img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
@ -423,8 +352,8 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" size="sm" onClick={() => addVariant(selectedMaterial, price.id)}> <Button type="button" variant="outline" size="sm" onClick={() => addVariant(price.id)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</Button> </Button>

View File

@ -866,8 +866,8 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
] ?? ] ??
0) > 0) >
0 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -909,7 +909,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -307,8 +307,8 @@ export default function PurchaseEdit({
] ?? ] ??
0) > 0) >
0 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -350,7 +350,7 @@ export default function PurchaseEdit({
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -308,8 +308,8 @@ return 0;
variant variant
.id .id
] ?? 0) > 0 ] ?? 0) > 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -351,7 +351,7 @@ return 0;
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -236,8 +236,8 @@ return 0;
variant variant
.id .id
] ?? 0) > 0 ] ?? 0) > 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -279,7 +279,7 @@ return 0;
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -84,6 +84,7 @@ export type TransactionForEdit = {
discount: number; discount: number;
nego_price: number | null; nego_price: number | null;
is_completed: boolean; is_completed: boolean;
is_affiliate: boolean;
tiktok_order_id: string | null; tiktok_order_id: string | null;
shopee_order_id: string | null; shopee_order_id: string | null;
notes: string | null; notes: string | null;

View File

@ -94,6 +94,7 @@ export default function TransactionCreate({
const [discount, setDiscount] = useState(draft?.discount ?? 0); const [discount, setDiscount] = useState(draft?.discount ?? 0);
const [negoPrice, setNegoPrice] = useState<number | null>(draft?.negoPrice ?? null); const [negoPrice, setNegoPrice] = useState<number | null>(draft?.negoPrice ?? null);
const [isCompleted, setIsCompleted] = useState(draft?.isCompleted ?? false); const [isCompleted, setIsCompleted] = useState(draft?.isCompleted ?? false);
const [isAffiliate, setIsAffiliate] = useState(draft?.isAffiliate ?? false);
const [notes, setNotes] = useState(draft?.notes ?? ''); const [notes, setNotes] = useState(draft?.notes ?? '');
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null); const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
const [photoUrl, setPhotoUrl] = useState<string | null>( const [photoUrl, setPhotoUrl] = useState<string | null>(
@ -132,6 +133,7 @@ export default function TransactionCreate({
discount, discount,
negoPrice, negoPrice,
isCompleted, isCompleted,
isAffiliate,
tiktokOrderId: channel === 'tiktok' ? tiktokOrderId : '', tiktokOrderId: channel === 'tiktok' ? tiktokOrderId : '',
shopeeOrderId: channel === 'shopee' ? shopeeOrderId : '', shopeeOrderId: channel === 'shopee' ? shopeeOrderId : '',
selectedProductId, selectedProductId,
@ -147,7 +149,7 @@ export default function TransactionCreate({
[ [
stockType, channel, priceType, paymentType, stockType, channel, priceType, paymentType,
customerId, marketingId, discount, negoPrice, customerId, marketingId, discount, negoPrice,
isCompleted, tiktokOrderId, shopeeOrderId, isCompleted, isAffiliate, tiktokOrderId, shopeeOrderId,
selectedProductId, quantities, notes, photo, selectedProductId, quantities, notes, photo,
], ],
); );
@ -291,6 +293,7 @@ export default function TransactionCreate({
discount, discount,
nego_price: negoPrice, nego_price: negoPrice,
is_completed: isCompleted, is_completed: isCompleted,
is_affiliate: isAffiliate,
tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null, tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null,
shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null, shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null,
items: Object.entries(quantitiesRef.current) items: Object.entries(quantitiesRef.current)
@ -403,8 +406,8 @@ export default function TransactionCreate({
variant variant
.id .id
] ?? 0) > 0 ] ?? 0) > 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -450,7 +453,7 @@ export default function TransactionCreate({
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -850,6 +853,17 @@ export default function TransactionCreate({
/> />
</div> </div>
<div className="flex items-center justify-between">
<Label htmlFor="is_affiliate">
Affiliasi
</Label>
<Switch
id="is_affiliate"
checked={isAffiliate}
onCheckedChange={setIsAffiliate}
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="notes"> <Label htmlFor="notes">
Keterangan Keterangan

View File

@ -90,6 +90,7 @@ export default function TransactionEdit({
const [discount, setDiscount] = useState(transaction.discount); const [discount, setDiscount] = useState(transaction.discount);
const [negoPrice, setNegoPrice] = useState<number | null>(transaction.nego_price); const [negoPrice, setNegoPrice] = useState<number | null>(transaction.nego_price);
const [isCompleted, setIsCompleted] = useState(transaction.is_completed); const [isCompleted, setIsCompleted] = useState(transaction.is_completed);
const [isAffiliate, setIsAffiliate] = useState(transaction.is_affiliate);
const [notes, setNotes] = useState(transaction.notes ?? ''); const [notes, setNotes] = useState(transaction.notes ?? '');
const [photo, setPhoto] = useState<string | null>(transaction.photo_key); const [photo, setPhoto] = useState<string | null>(transaction.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(transaction.photo_url); const [photoUrl, setPhotoUrl] = useState<string | null>(transaction.photo_url);
@ -262,6 +263,7 @@ export default function TransactionEdit({
discount, discount,
nego_price: negoPrice, nego_price: negoPrice,
is_completed: isCompleted, is_completed: isCompleted,
is_affiliate: isAffiliate,
tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null, tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null,
shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null, shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null,
items: Object.entries(quantitiesRef.current) items: Object.entries(quantitiesRef.current)
@ -383,8 +385,8 @@ export default function TransactionEdit({
variant variant
.id .id
] ?? 0) > 0 ] ?? 0) > 0
? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' : 'flex items-center justify-between gap-3 rounded-lg border p-3'
} }
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
@ -430,7 +432,7 @@ export default function TransactionEdit({
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -830,6 +832,17 @@ export default function TransactionEdit({
/> />
</div> </div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-is_affiliate">
Affiliasi
</Label>
<Switch
id="edit-is_affiliate"
checked={isAffiliate}
onCheckedChange={setIsAffiliate}
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-notes"> <Label htmlFor="edit-notes">
Keterangan Keterangan

View File

@ -54,6 +54,7 @@ const actionLabels: Record<string, string> = {
'update-system': 'Update Sistem', 'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage', 'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial', 'update-social-media': 'Update Media Sosial',
'update-marketplace': 'Update Marketplace',
'update-hr': 'Update HR', 'update-hr': 'Update HR',
}; };

View File

@ -64,6 +64,7 @@ const actionLabels: Record<string, string> = {
'update-system': 'Update Sistem', 'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage', 'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial', 'update-social-media': 'Update Media Sosial',
'update-marketplace': 'Update Marketplace',
'update-hr': 'Update HR', 'update-hr': 'Update HR',
}; };

View File

@ -10,16 +10,25 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
updateHomepage, updateHomepage,
updateHr, updateHr,
updateMarketplace,
updateSocialMedia, updateSocialMedia,
updateSystem, updateSystem,
} from '@/routes/admin/settings'; } from '@/routes/admin/settings';
type MarketplaceFeeRule = {
base: string;
type: string;
value: number;
};
type Props = { type Props = {
system: { system: {
app_name: string; app_name: string;
@ -41,6 +50,10 @@ type Props = {
facebook_url: string; facebook_url: string;
tiktok_url: string; tiktok_url: string;
}; };
marketplace: {
tiktok_shop: Record<string, MarketplaceFeeRule>;
shopee: Record<string, MarketplaceFeeRule>;
};
hr: { hr: {
scheduled_check_in_time: string; scheduled_check_in_time: string;
scheduled_check_out_time: string; scheduled_check_out_time: string;
@ -227,18 +240,252 @@ const sidebarTabs = [
{ key: 'sistem', label: 'Sistem' }, { key: 'sistem', label: 'Sistem' },
{ key: 'homepage', label: 'Homepage' }, { key: 'homepage', label: 'Homepage' },
{ key: 'media-sosial', label: 'Media Sosial' }, { key: 'media-sosial', label: 'Media Sosial' },
{ key: 'marketplace', label: 'Marketplace' },
{ key: 'hr', label: 'HR' }, { key: 'hr', label: 'HR' },
] as const; ] as const;
type TabKey = (typeof sidebarTabs)[number]['key']; type TabKey = (typeof sidebarTabs)[number]['key'];
function MarketplaceVariableInput({
label,
prefix,
data,
}: {
label: string;
prefix: string;
data: MarketplaceFeeRule;
}) {
const [type, setType] = useState(data.type);
return (
<div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4">
<span className="min-w-[160px] text-sm font-medium md:pt-2">
{label}
</span>
<div className="grid flex-1 grid-cols-1 gap-3 md:grid-cols-3">
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">
Dasar
</span>
<RadioGroup
name={`${prefix}[base]`}
defaultValue={data.base}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="per_transaksi"
id={`${prefix}-base-per_transaksi`}
/>
<Label
htmlFor={`${prefix}-base-per_transaksi`}
className="text-xs font-normal"
>
Per Transaksi
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="per_produk"
id={`${prefix}-base-per_produk`}
/>
<Label
htmlFor={`${prefix}-base-per_produk`}
className="text-xs font-normal"
>
Per Produk
</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">
Tipe
</span>
<RadioGroup
name={`${prefix}[type]`}
defaultValue={data.type}
onValueChange={setType}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="flat"
id={`${prefix}-type-flat`}
/>
<Label
htmlFor={`${prefix}-type-flat`}
className="text-xs font-normal"
>
Flat
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="persentase"
id={`${prefix}-type-persentase`}
/>
<Label
htmlFor={`${prefix}-type-persentase`}
className="text-xs font-normal"
>
Persentase
</Label>
</div>
</RadioGroup>
</div>
<div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">
Nilai
</span>
{type === 'flat' ? (
<RupiahInput
name={`${prefix}[value]`}
defaultValue={data.value}
/>
) : (
<Input
name={`${prefix}[value]`}
type="number"
step="0.01"
min="0"
defaultValue={data.value}
placeholder="0"
/>
)}
</div>
</div>
</div>
);
}
function MarketplaceColumnHeader() {
return (
<div className="hidden gap-3 px-3 pb-1 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[160px_1fr] md:gap-4">
<span>Biaya</span>
<div className="grid flex-1 grid-cols-3 gap-3">
<span>Dasar</span>
<span>Tipe</span>
<span>Nilai</span>
</div>
</div>
);
}
function MarketplaceCard({
title,
variables,
}: {
title: string;
variables: { label: string; prefix: string; data: MarketplaceFeeRule }[];
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2">
<MarketplaceColumnHeader />
{variables.map((v) => (
<MarketplaceVariableInput key={v.prefix} {...v} />
))}
</CardContent>
</Card>
);
}
export default function AdminSettings({ export default function AdminSettings({
system, system,
homepage, homepage,
socialMedia, socialMedia,
marketplace,
hr, hr,
}: Props) { }: Props) {
const [activeTab, setActiveTab] = useState<TabKey>('sistem'); const [activeTab, setActiveTab] = useState<TabKey>('sistem');
const [marketplaceTab, setMarketplaceTab] = useState<
'tiktok-shop' | 'shopee'
>('tiktok-shop');
const tiktokShopVariables = [
{
label: 'Komisi Platform',
prefix: 'tiktok_shop_platform_commission',
data: marketplace.tiktok_shop.platform_commission,
},
{
label: 'Layanan Logistik',
prefix: 'tiktok_shop_logistics_service_fee',
data: marketplace.tiktok_shop.logistics_service_fee,
},
{
label: 'Komisi Dinamis',
prefix: 'tiktok_shop_dynamic_commission',
data: marketplace.tiktok_shop.dynamic_commission,
},
{
label: 'Pemrosesan Pesanan',
prefix: 'tiktok_shop_order_processing_fee',
data: marketplace.tiktok_shop.order_processing_fee,
},
{
label: 'Affiliate',
prefix: 'tiktok_shop_affiliate',
data: marketplace.tiktok_shop.affiliate,
},
{
label: 'Layanan PO',
prefix: 'tiktok_shop_pre_order_service_fee',
data: marketplace.tiktok_shop.pre_order_service_fee,
},
];
const shopeeVariables = [
{
label: 'Biaya Administrasi',
prefix: 'shopee_admin_fee',
data: marketplace.shopee.admin_fee,
},
{
label: 'Biaya Program',
prefix: 'shopee_program_fee',
data: marketplace.shopee.program_fee,
},
{
label: 'Hemat Biaya Kirim',
prefix: 'shopee_shipping_savings',
data: marketplace.shopee.shipping_savings,
},
{
label: 'Premi',
prefix: 'shopee_premium',
data: marketplace.shopee.premium,
},
{
label: 'Biaya Layanan',
prefix: 'shopee_service_fee',
data: marketplace.shopee.service_fee,
},
{
label: 'Biaya Proses Pesanan',
prefix: 'shopee_order_processing_fee',
data: marketplace.shopee.order_processing_fee,
},
{
label: 'Biaya Komisi AMS',
prefix: 'shopee_ams_commission_fee',
data: marketplace.shopee.ams_commission_fee,
},
{
label: 'PO',
prefix: 'shopee_pre_order',
data: marketplace.shopee.pre_order,
},
{
label: 'Live Extra',
prefix: 'shopee_live_extra',
data: marketplace.shopee.live_extra,
},
];
return ( return (
<> <>
@ -493,6 +740,68 @@ export default function AdminSettings({
</Form> </Form>
)} )}
{activeTab === 'marketplace' && (
<Form
action={updateMarketplace()}
options={{ preserveScroll: true }}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
>
{({ processing }) => (
<div className="grid gap-6">
<Tabs
value={marketplaceTab}
onValueChange={(v) =>
setMarketplaceTab(
v as
| 'tiktok-shop'
| 'shopee',
)
}
>
<TabsList className="mb-3">
<TabsTrigger value="tiktok-shop">
TikTok Shop
</TabsTrigger>
<TabsTrigger value="shopee">
Shopee
</TabsTrigger>
</TabsList>
</Tabs>
<div
hidden={
marketplaceTab !== 'tiktok-shop'
}
>
<MarketplaceCard
title="TikTok Shop"
variables={tiktokShopVariables}
/>
</div>
<div
hidden={marketplaceTab !== 'shopee'}
>
<MarketplaceCard
title="Shopee"
variables={shopeeVariables}
/>
</div>
<div className="flex items-center gap-4">
<Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div>
</div>
)}
</Form>
)}
{activeTab === 'hr' && ( {activeTab === 'hr' && (
<Form <Form
action={updateHr()} action={updateHr()}

View File

@ -8,7 +8,6 @@ import {
DialogContent, DialogContent,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { generateRandomColors } from '@/lib/utils';
import { formatRupiah } from '@/lib/rupiah'; import { formatRupiah } from '@/lib/rupiah';
import { store, update } from '@/routes/admin/hr/attendances'; import { store, update } from '@/routes/admin/hr/attendances';
import { Head, router, usePage } from '@inertiajs/react'; import { Head, router, usePage } from '@inertiajs/react';
@ -468,28 +467,34 @@ type DashboardPieChartProps = {
nameKey: string; nameKey: string;
}; };
const PIE_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
];
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) { function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0); const hasData = data.length > 0 && data.some((d) => d.count > 0);
const pieColors = useMemo(() => generateRandomColors(5), []);
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
const config: ChartConfig = {}; const config: ChartConfig = {};
data.forEach((item, index) => { data.forEach((item, index) => {
config[item.name] = { config[item.name] = {
label: item.name, label: item.name,
color: pieColors[index % pieColors.length], color: PIE_COLORS[index % PIE_COLORS.length],
}; };
}); });
return config; return config;
}, [data, pieColors]); }, [data]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
return data.map((item) => ({ return data.map((item) => ({
...item, ...item,
fill: pieColors[data.indexOf(item) % pieColors.length], fill: PIE_COLORS[data.indexOf(item) % PIE_COLORS.length],
})); }));
}, [data, pieColors]); }, [data]);
return ( return (
<Card> <Card>

View File

@ -3,16 +3,8 @@
use App\Console\Commands\GeneratePayrollCommand; use App\Console\Commands\GeneratePayrollCommand;
use App\Jobs\CheckAttendancePenaltiesJob; use App\Jobs\CheckAttendancePenaltiesJob;
use App\Jobs\CleanupOrphanedMediaJob; use App\Jobs\CleanupOrphanedMediaJob;
use App\Jobs\SendAttendanceReminderJob;
use Carbon\Carbon;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
Schedule::command(GeneratePayrollCommand::class) Schedule::command(GeneratePayrollCommand::class)->monthlyOn(1, '00:00');
->daily() Schedule::job(new CheckAttendancePenaltiesJob)->dailyAt('00:00');
->when(fn () => now()->day === 5)
->at('00:00');
Schedule::job(new SendAttendanceReminderJob)
->dailyAt('07:00')
->when(fn () => now()->dayOfWeek !== Carbon::SUNDAY);
Schedule::job(new CheckAttendancePenaltiesJob)->dailyAt('12:00');
Schedule::job(new CleanupOrphanedMediaJob)->monthlyOn(1, '01:00'); Schedule::job(new CleanupOrphanedMediaJob)->monthlyOn(1, '01:00');

View File

@ -116,10 +116,11 @@
}); });
Route::prefix('settings')->name('admin.settings.')->group(function () { Route::prefix('settings')->name('admin.settings.')->group(function () {
Route::get('/', [AdminSettingsController::class, 'index'])->name('index')->middleware('permission:settings.view_system|settings.view_homepage|settings.view_social_media|settings.view_hr'); Route::get('/', [AdminSettingsController::class, 'index'])->name('index')->middleware('permission:settings.view_system|settings.view_homepage|settings.view_social_media|settings.view_marketplace|settings.view_hr');
Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system')->middleware('permission:settings.update_system'); Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system')->middleware('permission:settings.update_system');
Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage')->middleware('permission:settings.update_homepage'); Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage')->middleware('permission:settings.update_homepage');
Route::put('social-media', [AdminSettingsController::class, 'updateSocialMedia'])->name('update-social-media')->middleware('permission:settings.update_social_media'); Route::put('social-media', [AdminSettingsController::class, 'updateSocialMedia'])->name('update-social-media')->middleware('permission:settings.update_social_media');
Route::put('marketplace', [AdminSettingsController::class, 'updateMarketplace'])->name('update-marketplace')->middleware('permission:settings.update_marketplace');
Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr')->middleware('permission:settings.update_hr'); Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr')->middleware('permission:settings.update_hr');
}); });

View File

@ -3,8 +3,10 @@
use App\Models\User; use App\Models\User;
use App\Settings\HomepageSettings; use App\Settings\HomepageSettings;
use App\Settings\HRSettings; use App\Settings\HRSettings;
use App\Settings\MarketplaceSettings;
use App\Settings\SocialMediaSettings; use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings; use App\Settings\SystemSettings;
use App\Support\Marketplace\MarketplaceFeeRule;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
@ -42,6 +44,13 @@
$response->assertRedirect(route('login')); $response->assertRedirect(route('login'));
}); });
test('guest cannot update marketplace settings', function () {
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 100],
]);
$response->assertRedirect(route('login'));
});
test('guest cannot update hr settings', function () { test('guest cannot update hr settings', function () {
$response = $this->put(route('admin.settings.update-hr'), [ $response = $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '09:00', 'scheduled_check_in_time' => '09:00',
@ -73,6 +82,7 @@
->has('system') ->has('system')
->has('homepage') ->has('homepage')
->has('socialMedia') ->has('socialMedia')
->has('marketplace')
->has('hr') ->has('hr')
); );
}); });
@ -91,6 +101,21 @@
); );
}); });
test('settings page contains marketplace data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$marketplace = app(MarketplaceSettings::class);
$response = $this->get(route('admin.settings.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('marketplace.tiktok_shop.platform_commission.base', $marketplace->tiktok_shop_platform_commission->base)
->where('marketplace.tiktok_shop.platform_commission.type', $marketplace->tiktok_shop_platform_commission->type)
->where('marketplace.tiktok_shop.platform_commission.value', json_decode(json_encode($marketplace->tiktok_shop_platform_commission->value)))
->where('marketplace.shopee.admin_fee.base', $marketplace->shopee_admin_fee->base)
);
});
test('settings page contains hr data', function () { test('settings page contains hr data', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@ -364,6 +389,243 @@
$response->assertSessionHasErrors('tiktok_url'); $response->assertSessionHasErrors('tiktok_url');
}); });
/*
|--------------------------------------------------------------------------
| UPDATE MARKETPLACE
|--------------------------------------------------------------------------
*/
test('user can update marketplace settings with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 5000];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.settings.index'));
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission->base)->toBe('per_transaksi');
expect($settings->tiktok_shop_platform_commission->type)->toBe('flat');
expect($settings->tiktok_shop_platform_commission->value)->toBe(5000.0);
expect($settings->shopee_admin_fee->base)->toBe('per_transaksi');
expect($settings->shopee_admin_fee->type)->toBe('flat');
expect($settings->shopee_admin_fee->value)->toBe(5000.0);
});
test('marketplace accepts percentage type', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_produk', 'type' => 'persentase', 'value' => 2.5];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasNoErrors();
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission->base)->toBe('per_produk');
expect($settings->tiktok_shop_platform_commission->type)->toBe('persentase');
expect($settings->tiktok_shop_platform_commission->value)->toBe(2.5);
});
test('marketplace base field must be valid', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'invalid', 'type' => 'flat', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.base');
});
test('marketplace type field must be valid', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'invalid', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.type');
});
test('marketplace value must be numeric', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 'not-a-number'];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace value must be at least 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => -1];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace fee rule array is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => 'not-an-array',
'tiktok_shop_logistics_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_dynamic_commission' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_order_processing_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_affiliate' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'tiktok_shop_pre_order_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_admin_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_program_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_shipping_savings' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_premium' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_service_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_order_processing_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_ams_commission_fee' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_pre_order' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
'shopee_live_extra' => ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0],
]);
$response->assertSessionHasErrors('tiktok_shop_platform_commission');
});
test('marketplace each fee key must be provided', function () {
$user = User::factory()->create();
$this->actingAs($user);
$feeRule = ['base' => 'per_transaksi', 'type' => 'flat', 'value' => 0];
$response = $this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
// Missing other required keys
]);
$response->assertSessionHasErrors([
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
]);
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| UPDATE HR | UPDATE HR
@ -515,6 +777,36 @@
expect($settings->absent_penalty_amount)->toBeInt(); expect($settings->absent_penalty_amount)->toBeInt();
}); });
test('marketplace settings default values are MarketplaceFeeRule', function () {
$settings = app(MarketplaceSettings::class);
expect($settings->tiktok_shop_platform_commission)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_logistics_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_dynamic_commission)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_order_processing_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_affiliate)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->tiktok_shop_pre_order_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_admin_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_program_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_shipping_savings)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_premium)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_service_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_order_processing_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_ams_commission_fee)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_pre_order)->toBeInstanceOf(MarketplaceFeeRule::class);
expect($settings->shopee_live_extra)->toBeInstanceOf(MarketplaceFeeRule::class);
});
test('marketplace fee rule to array contains correct keys', function () {
$rule = MarketplaceFeeRule::defaultPercent(0.0);
$array = $rule->toArray();
expect($array)->toHaveKeys(['base', 'type', 'value']);
expect($array['base'])->toBe('per_transaksi');
expect($array['type'])->toBe('persentase');
expect($array['value'])->toBe(0.0);
});
test('updating one setting group does not affect others', function () { test('updating one setting group does not affect others', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@ -570,6 +862,26 @@
'tiktok_url' => 'https://tiktok.com/@newdst', 'tiktok_url' => 'https://tiktok.com/@newdst',
])->assertSessionHasNoErrors(); ])->assertSessionHasNoErrors();
// Update marketplace
$feeRule = ['base' => 'per_transaksi', 'type' => 'persentase', 'value' => 3.0];
$this->put(route('admin.settings.update-marketplace'), [
'tiktok_shop_platform_commission' => $feeRule,
'tiktok_shop_logistics_service_fee' => $feeRule,
'tiktok_shop_dynamic_commission' => $feeRule,
'tiktok_shop_order_processing_fee' => $feeRule,
'tiktok_shop_affiliate' => $feeRule,
'tiktok_shop_pre_order_service_fee' => $feeRule,
'shopee_admin_fee' => $feeRule,
'shopee_program_fee' => $feeRule,
'shopee_shipping_savings' => $feeRule,
'shopee_premium' => $feeRule,
'shopee_service_fee' => $feeRule,
'shopee_order_processing_fee' => $feeRule,
'shopee_ams_commission_fee' => $feeRule,
'shopee_pre_order' => $feeRule,
'shopee_live_extra' => $feeRule,
])->assertSessionHasNoErrors();
// Update HR // Update HR
$this->put(route('admin.settings.update-hr'), [ $this->put(route('admin.settings.update-hr'), [
'scheduled_check_in_time' => '08:30', 'scheduled_check_in_time' => '08:30',
@ -582,11 +894,13 @@
$system = app(SystemSettings::class); $system = app(SystemSettings::class);
$homepage = app(HomepageSettings::class); $homepage = app(HomepageSettings::class);
$socialMedia = app(SocialMediaSettings::class); $socialMedia = app(SocialMediaSettings::class);
$marketplace = app(MarketplaceSettings::class);
$hr = app(HRSettings::class); $hr = app(HRSettings::class);
expect($system->app_name)->toBe('DST Updated'); expect($system->app_name)->toBe('DST Updated');
expect($homepage->hero_image_url)->toBe('homepage/hero/2026/07/31/new-hero.jpg'); expect($homepage->hero_image_url)->toBe('homepage/hero/2026/07/31/new-hero.jpg');
expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst'); expect($socialMedia->instagram_url)->toBe('https://instagram.com/newdst');
expect($marketplace->tiktok_shop_platform_commission->value)->toBe(3.0);
expect($hr->scheduled_check_in_time)->toBe('08:30'); expect($hr->scheduled_check_in_time)->toBe('08:30');
expect($hr->late_penalty_amount)->toBe(15000); expect($hr->late_penalty_amount)->toBe(15000);
}); });

View File

@ -25,7 +25,7 @@
'payroll-adjustment' => ['create', 'delete'], 'payroll-adjustment' => ['create', 'delete'],
'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'], 'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'],
'attendance' => ['view', 'check-in', 'check-out', 'by-date'], 'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-hr'], 'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
]; ];
foreach ($modules as $module => $actions) { foreach ($modules as $module => $actions) {