feat: add partially paid status to employee advances and update related functionalities

This commit is contained in:
Yoga Pangestu 2026-06-27 20:07:12 +07:00
parent eb5eeaf73b
commit 7c0b59b998
30 changed files with 271 additions and 157 deletions

View File

@ -11,6 +11,7 @@ enum EmployeeAdvanceStatus: string
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
case PARTIALLY_PAID = 'partially_paid';
case PAID = 'paid';
public function label(): string
@ -19,6 +20,7 @@ public function label(): string
self::PENDING => 'Menunggu',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
self::PARTIALLY_PAID => 'Dibayar Sebagian',
self::PAID => 'Lunas',
};
}

View File

@ -13,7 +13,8 @@ enum Role: string
case ADMIN_TOKO = 'admin-toko';
case ADMIN_BAHAN_BAKU = 'admin-bahan-baku';
case DIREKTUR = 'direktur';
case MARKETING = 'marketing';
case MARKETING_OFFLINE = 'marketing-offline';
case MARKETING_ONLINE = 'marketing-online';
case CASHIER = 'cashier';
case NON_OPERATOR = 'non-operator';
@ -25,7 +26,8 @@ public function label(): string
self::ADMIN_TOKO => 'Admin Toko',
self::ADMIN_BAHAN_BAKU => 'Admin Bahan Baku',
self::DIREKTUR => 'Direktur',
self::MARKETING => 'Marketing',
self::MARKETING_OFFLINE => 'Marketing Offline',
self::MARKETING_ONLINE => 'Marketing Online',
self::CASHIER => 'Kasir',
self::NON_OPERATOR => 'Non Operator',
};
@ -63,7 +65,6 @@ public function permissions(): array
Permission::ANALYSIS_ATTENDANCE,
Permission::ANALYSIS_CASH,
Permission::ANALYSIS_RAW_MATERIALS,
Permission::ANALYSIS_PRODUCT_STOCK,
Permission::ANALYSIS_REVENUE,
Permission::ANALYSIS_EXPENSE,
@ -72,14 +73,11 @@ public function permissions(): array
Permission::ANALYSIS_PROFIT_HPP,
Permission::ANALYSIS_PROFIT_GROSS,
Permission::ANALYSIS_PROFIT_MARGIN,
Permission::ANALYSIS_TOP_SUPPLIERS,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::EMPLOYEES_VIEW,
Permission::STOCKS_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
@ -92,16 +90,10 @@ public function permissions(): array
Permission::CATEGORIES_VIEW,
Permission::SUPPLIERS_VIEW,
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::RAW_MATERIALS_VIEW,
Permission::PURCHASES_VIEW,
Permission::ORDERS_VIEW,
Permission::CUTTINGS_VIEW,
@ -203,7 +195,6 @@ public function permissions(): array
self::CASHIER => [
Permission::DASHBOARD_VIEW,
Permission::DASHBOARD_ATTENDANCE,
Permission::DASHBOARD_CASH,
Permission::DASHBOARD_REVENUE,
Permission::DASHBOARD_ORDERS_CHANNEL,
Permission::DASHBOARD_ORDERS_PAYMENT,
@ -211,13 +202,8 @@ public function permissions(): array
Permission::ANALYSIS_VIEW,
Permission::ANALYSIS_ATTENDANCE,
Permission::ANALYSIS_CASH,
Permission::ANALYSIS_REVENUE,
Permission::ANALYSIS_EXPENSE,
Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_PROFIT_HPP,
Permission::ANALYSIS_PROFIT_GROSS,
Permission::ANALYSIS_PROFIT_MARGIN,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ANALYSIS_TOP_PRODUCTS,
@ -303,7 +289,7 @@ public function permissions(): array
Permission::PAYROLL_VIEW,
],
self::MARKETING => [
self::MARKETING_OFFLINE, self::MARKETING_ONLINE => [
Permission::DASHBOARD_VIEW,
Permission::DASHBOARD_ATTENDANCE,
Permission::DASHBOARD_REVENUE,
@ -316,10 +302,6 @@ public function permissions(): array
Permission::ANALYSIS_ATTENDANCE,
Permission::ANALYSIS_REVENUE,
Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_PROFIT_HPP,
Permission::ANALYSIS_PROFIT_GROSS,
Permission::ANALYSIS_PROFIT_MARGIN,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ATTENDANCES_VIEW,
@ -330,8 +312,6 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,

View File

@ -91,11 +91,13 @@ public function reject(RejectEmployeeAdvanceRequest $request, EmployeeAdvance $e
return redirect()->route('admin.finance.employee_advances.index');
}
public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse
public function pay(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->pay($employeeAdvance, auth()->user());
$payAmount = $request->input('amount') ? (int) $request->input('amount') : null;
$this->flashSuccess('Kasbon berhasil dilunasi.');
$this->employeeAdvanceService->pay($employeeAdvance, auth()->user(), $payAmount);
$this->flashSuccess('Kasbon berhasil dibayar.');
return redirect()->route('admin.finance.employee_advances.index');
}

View File

@ -38,7 +38,6 @@ public function rules(): array
'tiktok_order_id' => [Rule::requiredIf(fn () => $this->channel === OrderChannel::TIKTOK->value), 'string', 'max:100'],
'shopee_order_id' => [Rule::requiredIf(fn () => $this->channel === OrderChannel::SHOPEE->value), 'string', 'max:100'],
'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string'],
];
@ -71,7 +70,6 @@ public function attributes(): array
'tiktok_order_id' => 'ID pesanan TikTok Shop',
'shopee_order_id' => 'ID pesanan Shopee',
'discount' => 'diskon',
'shipping_cost' => 'ongkos kirim',
'notes' => 'keterangan',
'items' => 'produk',
'items.*.product_variant_id' => 'varian produk',
@ -82,16 +80,18 @@ public function attributes(): array
public function withValidator(Validator $validator): void
{
if (! $this->isMethod('PUT') && ! $this->isMethod('PATCH')) {
return;
}
$validator->after(function (Validator $validator): void {
/** @var Order $order */
$order = $this->route('order');
if ($this->filled('marketing_id') && $this->marketing_id !== 'none' && ! $this->filled('customer_id')) {
$validator->errors()->add('customer_id', 'Pelanggan wajib diisi jika marketing dipilih.');
}
if (! $order->status->isEditable()) {
$validator->errors()->add('status', 'Pesanan tidak dapat diubah.');
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
/** @var Order $order */
$order = $this->route('order');
if (! $order->status->isEditable()) {
$validator->errors()->add('status', 'Pesanan tidak dapat diubah.');
}
}
});
}

View File

@ -17,6 +17,9 @@
#[Guarded(['id'])]
#[Appends([
'amount_formatted',
'paid_amount_formatted',
'remaining_amount',
'remaining_amount_formatted',
'due_date_formatted',
'due_date_input',
'status_label',
@ -35,6 +38,7 @@ protected function casts(): array
{
return [
'amount' => 'integer',
'paid_amount' => 'integer',
'due_date' => 'date',
'status' => EmployeeAdvanceStatus::class,
'verified_at' => 'datetime',
@ -74,10 +78,31 @@ public function amountFormatted(): Attribute
);
}
public function paidAmountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->paid_amount, 0, ',', '.'),
);
}
public function remainingAmount(): Attribute
{
return Attribute::make(
get: fn () => $this->amount - $this->paid_amount,
);
}
public function remainingAmountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->remaining_amount, 0, ',', '.'),
);
}
public function canPay(): Attribute
{
return Attribute::make(
get: fn () => $this->status === EmployeeAdvanceStatus::APPROVED,
get: fn () => in_array($this->status, [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID], true),
);
}

View File

@ -22,7 +22,6 @@
#[Appends([
'subtotal_formatted',
'discount_formatted',
'shipping_cost_formatted',
'total_amount_formatted',
'created_at_formatted',
'channel_label',
@ -46,7 +45,6 @@ protected function casts(): array
'status' => OrderStatus::class,
'subtotal' => 'integer',
'discount' => 'integer',
'shipping_cost' => 'integer',
'marketplace_settings_snapshot' => 'array',
'total_amount' => 'integer',
];
@ -98,13 +96,6 @@ public function discountFormatted(): Attribute
);
}
public function shippingCostFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->shipping_cost, 0, ',', '.'),
);
}
public function totalAmountFormatted(): Attribute
{
return Attribute::make(

View File

@ -129,8 +129,9 @@ public function calculateKasbonDeduction(): int
{
$outstanding = (int) EmployeeAdvance::query()
->where('employee_id', $this->employee_id)
->where('status', EmployeeAdvanceStatus::APPROVED)
->sum('amount');
->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID])
->selectRaw('SUM(amount - paid_amount) as remaining')
->value('remaining');
return min($outstanding, (int) $this->base_salary + (int) $this->adjustments()
->where('type', PayrollAdjustmentType::BONUS)

View File

@ -28,10 +28,11 @@ public function __construct(
*/
public function outstandingSummary(?User $user = null): array
{
$query = EmployeeAdvance::query()->approved()
$query = EmployeeAdvance::query()
->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID])
->when(! $user->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]), fn (Builder $query) => $query->where('employee_id', $user->employee?->id ?? -1));
$outstandingAmount = (int) $query->sum('amount');
$outstandingAmount = (int) $query->selectRaw('SUM(amount - paid_amount) as remaining')->value('remaining');
$outstandingCount = $query->count();
@ -242,29 +243,37 @@ public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $u
}
}
public function pay(EmployeeAdvance $employeeAdvance, User $user): void
public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmount = null): void
{
try {
DB::transaction(function () use ($employeeAdvance, $user): void {
DB::transaction(function () use ($employeeAdvance, $user, $payAmount): void {
$employeeAdvance->loadMissing('employee.user.profile');
$remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount;
$amountToPay = $payAmount !== null ? min($payAmount, $remaining) : $remaining;
$isFullPayment = ($employeeAdvance->paid_amount + $amountToPay) >= $employeeAdvance->amount;
$description = sprintf(
'Pelunasan kasbon: %s',
'%s kasbon: %s',
$isFullPayment ? 'Pelunasan' : 'Pembayaran sebagian kasbon',
$employeeAdvance->employeeName,
);
$cashTransaction = $this->cashService->recordIncoming(
$employeeAdvance,
$employeeAdvance->amount,
$amountToPay,
$description,
$user,
);
$newPaidAmount = $employeeAdvance->paid_amount + $amountToPay;
$employeeAdvance->update([
'repayment_cash_transaction_id' => $cashTransaction->id,
'paid_at' => now(),
'paid_by_id' => $user->id,
'status' => EmployeeAdvanceStatus::PAID,
'paid_amount' => $newPaidAmount,
'repayment_cash_transaction_id' => $isFullPayment ? $cashTransaction->id : $employeeAdvance->repayment_cash_transaction_id,
'paid_at' => $isFullPayment ? now() : $employeeAdvance->paid_at,
'paid_by_id' => $isFullPayment ? $user->id : $employeeAdvance->paid_by_id,
'status' => $isFullPayment ? EmployeeAdvanceStatus::PAID : EmployeeAdvanceStatus::PARTIALLY_PAID,
]);
});
} catch (ValidationException $e) {

View File

@ -437,7 +437,7 @@ private function settleKasbonFromPayroll(Payroll $payroll, User $user): void
$advances = EmployeeAdvance::query()
->where('employee_id', $payroll->employee_id)
->where('status', EmployeeAdvanceStatus::APPROVED)
->whereIn('status', [EmployeeAdvanceStatus::APPROVED, EmployeeAdvanceStatus::PARTIALLY_PAID])
->orderBy('created_at')
->get();
@ -446,12 +446,21 @@ private function settleKasbonFromPayroll(Payroll $payroll, User $user): void
break;
}
$advance->paid_at = now();
$advance->paid_by_id = $user->id;
$advance->status = EmployeeAdvanceStatus::PAID;
$advanceRemaining = $advance->amount - $advance->paid_amount;
$paymentAmount = min($remaining, $advanceRemaining);
$advance->paid_amount += $paymentAmount;
if ($advance->paid_amount >= $advance->amount) {
$advance->paid_at = now();
$advance->paid_by_id = $user->id;
$advance->status = EmployeeAdvanceStatus::PAID;
} else {
$advance->status = EmployeeAdvanceStatus::PARTIALLY_PAID;
}
$advance->save();
$remaining -= $advance->amount;
$remaining -= $paymentAmount;
}
}

View File

@ -87,7 +87,8 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
'items.productVariant.product:id,name',
'items.productVariant:id,product_id,name',
])
->when($user->hasRole('marketing'), fn (Builder $query) => $query->where('marketing_id', $user->id))
->when($user->hasAnyRole(['marketing-offline', 'marketing-online']), fn (Builder $query) => $query->where('marketing_id', $user->id))
->when($user->hasRole('cashier'), fn (Builder $query) => $query->where('created_by_id', $user->id))
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
@ -143,7 +144,7 @@ public function marketingOptions(): array
{
return User::query()
->active()
->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->where('name', 'marketing'))
->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', ['marketing-offline', 'marketing-online']))
->with('profile:user_id,full_name')
->orderBy('username')
->get(['id', 'username'])
@ -417,8 +418,7 @@ public function create(array $validated, User $user): Order
$subtotal = $draftItems->sum('subtotal');
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$totalAmount = max($subtotal - $discount + $shippingCost, 0);
$totalAmount = max($subtotal - $discount, 0);
$channel = OrderChannel::from($validated['channel']);
$order = Order::create([
@ -434,7 +434,6 @@ public function create(array $validated, User $user): Order
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'shipping_cost' => $shippingCost,
'marketplace_settings_snapshot' => $this->marketplaceService->buildOrderSnapshot(
$channel,
$totalAmount,
@ -527,8 +526,7 @@ public function update(Order $order, array $validated): void
$lineItems = $this->buildLineItems($validated['items'], $priceType);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$totalAmount = max($subtotal - $discount + $shippingCost, 0);
$totalAmount = max($subtotal - $discount, 0);
$channel = OrderChannel::from($validated['channel']);
$order->customer_id = $validated['customer_id'] ?? null;
@ -541,7 +539,6 @@ public function update(Order $order, array $validated): void
$order->shopee_order_id = $validated['shopee_order_id'] ?? null;
$order->subtotal = $subtotal;
$order->discount = $discount;
$order->shipping_cost = $shippingCost;
$order->marketplace_settings_snapshot = $this->marketplaceService->buildOrderSnapshot(
$channel,
$totalAmount,

View File

@ -152,8 +152,14 @@ public function getTopSuppliers(?Carbon $startDate = null, ?Carbon $endDate = nu
public function getTopCustomers(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
return Order::query()
->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->join('customers', 'orders.customer_id', '=', 'customers.id')
->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count')
@ -171,14 +177,22 @@ public function getTopCustomers(?Carbon $startDate = null, ?Carbon $endDate = nu
public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$revenueSummary = Order::query()
->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders, AVG(total_amount) as avg_order')
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, COUNT(*) as total_orders, AVG(total_amount) as avg_order')
->first();
$totalMarketplaceFees = Order::query()
->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereNotNull('marketplace_settings_snapshot')
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->get()
@ -189,7 +203,6 @@ public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate =
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
'total_marketplace_fees' => $totalMarketplaceFees,
'total_potongan' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees,
'total_shipping' => (int) ($revenueSummary->total_shipping ?? 0),
'total_orders' => (int) ($revenueSummary->total_orders ?? 0),
'avg_order' => (int) ($revenueSummary->avg_order ?? 0),
];
@ -197,8 +210,17 @@ public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate =
public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = Order::query()->completed();
$feeQuery = Order::query()->completed()->whereNotNull('marketplace_settings_snapshot');
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$query = Order::query()->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id));
$feeQuery = Order::query()->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereNotNull('marketplace_settings_snapshot');
if ($startDate && $endDate) {
$query->whereBetween('orders.created_at', [$startDate, $endDate]);
@ -209,8 +231,7 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
->selectRaw("
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
SUM(total_amount) as total_revenue,
SUM(discount) as total_discount,
SUM(shipping_cost) as total_shipping
SUM(discount) as total_discount
")
->groupBy('month_key')
->orderBy('month_key')
@ -245,7 +266,6 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
'total' => (int) ($revenue->total_revenue ?? 0),
'net' => (int) ($revenue->total_revenue ?? 0) - $potongan,
'potongan' => $potongan,
'ongkir' => (int) ($revenue->total_shipping ?? 0),
];
$current->addMonth();
@ -353,7 +373,13 @@ public function getMonthlyExpense(?Carbon $startDate = null, ?Carbon $endDate =
public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$orderQuery = Order::query()->completed();
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$orderQuery = Order::query()->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id));
if ($startDate && $endDate) {
$orderQuery->whereBetween('orders.created_at', [$startDate, $endDate]);
}
@ -498,7 +524,13 @@ public function getProductStock(): array
public function getBusyHours(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$hourlyData = Order::query()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->selectRaw('HOUR(orders.created_at) as hour, COUNT(*) as order_count')
->groupBy('hour')
@ -519,11 +551,17 @@ public function getBusyHours(?Carbon $startDate = null, ?Carbon $endDate = null)
public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
return OrderItem::query()
->join('orders', 'order_items.order_id', '=', 'orders.id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
->where('orders.status', OrderStatus::COMPLETED)
->when($isMarketing, fn ($q) => $q->where('orders.marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('orders.created_by_id', $user->id))
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue")
->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name')

View File

@ -111,14 +111,22 @@ public function getCashOverview(): array
public function getRevenueSummary(): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$revenueSummary = Order::query()
->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereDate('created_at', Carbon::today())
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders, AVG(total_amount) as avg_order')
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, COUNT(*) as total_orders, AVG(total_amount) as avg_order')
->first();
$totalMarketplaceFees = Order::query()
->completed()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereNotNull('marketplace_settings_snapshot')
->whereDate('created_at', Carbon::today())
->get()
@ -129,7 +137,6 @@ public function getRevenueSummary(): array
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
'total_marketplace_fees' => $totalMarketplaceFees,
'total_potongan' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees,
'total_shipping' => (int) ($revenueSummary->total_shipping ?? 0),
'total_orders' => (int) ($revenueSummary->total_orders ?? 0),
'avg_order' => (int) ($revenueSummary->avg_order ?? 0),
];
@ -171,7 +178,13 @@ public function getExpenseSummary(): array
public function getOrderStats(): array
{
$user = auth()->user();
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
$isCashier = $user?->hasRole('cashier') ?? false;
$byChannel = Order::query()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereDate('created_at', Carbon::today())
->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total')
->groupBy('channel')
@ -184,6 +197,8 @@ public function getOrderStats(): array
]);
$byPaymentType = Order::query()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereDate('created_at', Carbon::today())
->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total')
->groupBy('payment_type')
@ -196,6 +211,8 @@ public function getOrderStats(): array
]);
$byMarketing = Order::query()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereNotNull('marketing_id')
->whereDate('orders.created_at', Carbon::today())
->join('users', 'orders.marketing_id', '=', 'users.id')
@ -212,6 +229,8 @@ public function getOrderStats(): array
]);
$byStatus = Order::query()
->when($isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
->when($isCashier, fn ($q) => $q->where('created_by_id', $user->id))
->whereDate('created_at', Carbon::today())
->selectRaw('status, COUNT(*) as count')
->groupBy('status')

View File

@ -64,7 +64,6 @@ private static function label(string $field): string
'supplier_id' => 'Supplier',
'subtotal' => 'Subtotal',
'discount' => 'Diskon',
'shipping_cost' => 'Ongkir',
'total' => 'Total',
'notes' => 'Keterangan',
'items' => 'Item Belanja',

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('employee_advances', function (Blueprint $table) {
$table->unsignedBigInteger('paid_amount')->default(0)->after('amount');
});
}
public function down(): void
{
Schema::table('employee_advances', function (Blueprint $table) {
$table->dropColumn('paid_amount');
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('shipping_cost');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('shipping_cost')->default(0)->after('discount');
});
}
};

View File

@ -33,10 +33,15 @@ public function run(): void
'email' => 'direktur@gmail.com',
'full_name' => 'Direktur',
],
Role::MARKETING->value => [
'username' => 'marketing',
'email' => 'marketing@gmail.com',
'full_name' => 'Marketing',
Role::MARKETING_OFFLINE->value => [
'username' => 'marketing-offline',
'email' => 'marketing.offline@gmail.com',
'full_name' => 'Marketing Offline',
],
Role::MARKETING_ONLINE->value => [
'username' => 'marketing-online',
'email' => 'marketing.online@gmail.com',
'full_name' => 'Marketing Online',
],
Role::CASHIER->value => [
'username' => 'cashier',

View File

@ -56,7 +56,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ title: 'Belanja', href: admin.manage.purchases.index.url(), icon: ShoppingBag, permission: 'purchases.view' },
{ title: 'Cutting', href: admin.manage.cuttings.index.url(), icon: Scissors, permission: 'cuttings.view' },
{ title: 'Stok', href: admin.manage.stocks.index.url(), icon: Warehouse, permission: 'stocks.view', badgeKey: 'pendingCuttings' },
{ title: 'Stok Gudang', href: admin.manage.stocks.index.url(), icon: Warehouse, permission: 'stocks.view', badgeKey: 'pendingCuttings' },
{ title: 'Verifikasi Owner', href: admin.manage.owner_verifications.index.url(), icon: CheckCircle, permission: 'owner_verifications.view', badgeKey: 'pendingOwnerVerifications' },
{ title: 'Pesanan', href: admin.manage.orders.index.url(), icon: ShoppingCart, permission: 'orders.view' },
],

View File

@ -2,5 +2,6 @@ export const EmployeeAdvanceStatus = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
PARTIALLY_PAID: 'partially_paid',
PAID: 'paid',
} as const;

View File

@ -97,14 +97,9 @@ export function encodeOrderReceipt(
const summaryRows: string[][] = [
['Subtotal', order.subtotal_formatted],
['Diskon', order.discount_formatted],
['Total', order.total_amount_formatted],
];
if (order.shipping_cost > 0) {
summaryRows.push(['Ongkir', order.shipping_cost_formatted]);
}
summaryRows.push(['Total', order.total_amount_formatted]);
encoder.table(
[
{ width: labelColumnWidth, align: 'left' },

View File

@ -71,7 +71,6 @@ const props = defineProps<{
total_discount: number;
total_marketplace_fees: number;
total_potongan: number;
total_shipping: number;
total_orders: number;
avg_order: number;
};
@ -80,7 +79,6 @@ const props = defineProps<{
total: number;
net: number;
potongan: number;
ongkir: number;
}>;
expenseSummary: {
total: number;
@ -168,7 +166,7 @@ function onPresetChange(value: any) {
}
// Revenue Chart
type MonthlyRevenueData = { month: string; total: number; net: number; potongan: number; ongkir: number };
type MonthlyRevenueData = { month: string; total: number; net: number; potongan: number };
const revenueChartConfig = {
total: {
@ -183,19 +181,14 @@ const revenueChartConfig = {
label: 'Potongan',
color: 'var(--chart-3)',
},
ongkir: {
label: 'Ongkir',
color: 'var(--chart-4)',
},
} satisfies ChartConfig;
const activeRevenueChart = ref<'total' | 'net' | 'potongan' | 'ongkir'>('total');
const activeRevenueChart = ref<'total' | 'net' | 'potongan'>('total');
const revenueTotals = computed(() => ({
total: props.revenueSummary.total_revenue,
net: props.revenueSummary.total_revenue - props.revenueSummary.total_potongan,
potongan: props.revenueSummary.total_potongan,
ongkir: props.revenueSummary.total_shipping,
}));
// Expense Chart
@ -458,7 +451,7 @@ watch([startDate, endDate], () => {
<CardTitle>Pendapatan</CardTitle>
</div>
<div class="flex">
<button v-for="chart in ['total', 'net', 'potongan', 'ongkir'] as const" :key="chart"
<button v-for="chart in ['total', 'net', 'potongan'] as const" :key="chart"
:data-active="activeRevenueChart === chart"
class="data-[active=true]:bg-muted/50 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
@click="activeRevenueChart = chart">

View File

@ -47,7 +47,6 @@ interface DashboardProps {
total_discount: number;
total_marketplace_fees: number;
total_potongan: number;
total_shipping: number;
total_orders: number;
avg_order: number;
};

View File

@ -10,6 +10,8 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
switch (status) {
case EmployeeAdvanceStatus.APPROVED:
return 'default';
case EmployeeAdvanceStatus.PARTIALLY_PAID:
return 'outline';
case EmployeeAdvanceStatus.PAID:
return 'secondary';
case EmployeeAdvanceStatus.REJECTED:

View File

@ -1,10 +1,13 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { RowApproveAction, RowDeleteAction, RowEditAction, RowPayAction, RowRejectAction } from '@/components/button';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { RupiahInput } from '@/components/form/rupiah-input';
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { useCan } from '@/composables/useCan';
import { parseRupiah } from '@/lib/rupiah';
import { destroy, approve, pay } from '@/routes/admin/finance/employee_advances';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
@ -43,6 +46,15 @@ const approveConfirmOpen = ref(false);
const payConfirmOpen = ref(false);
const approveProcessing = ref(false);
const payProcessing = ref(false);
const payAmount = ref('');
const payError = ref('');
watch(payConfirmOpen, (open) => {
if (open) {
payAmount.value = String(props.employeeAdvance.remaining_amount);
payError.value = '';
}
});
function approveEmployeeAdvance() {
approveProcessing.value = true;
@ -64,9 +76,22 @@ function approveEmployeeAdvance() {
}
function payEmployeeAdvance() {
const amount = Number(parseRupiah(payAmount.value));
if (!amount || amount <= 0) {
payError.value = 'Jumlah pembayaran harus lebih dari 0.';
return;
}
if (amount > props.employeeAdvance.remaining_amount) {
payError.value = 'Jumlah pembayaran melebihi sisa kasbon.';
return;
}
payError.value = '';
payProcessing.value = true;
router.post(pay.url(props.employeeAdvance.id), {}, {
router.post(pay.url(props.employeeAdvance.id), { amount }, {
preserveScroll: true,
onSuccess: () => {
payConfirmOpen.value = false;
@ -93,7 +118,7 @@ function payEmployeeAdvance() {
<RowRejectAction v-if="employeeAdvance.can_verify && can('employee_advances.verify')" tooltip="Tolak"
@click="emit('reject', employeeAdvance)" />
<RowPayAction v-if="employeeAdvance.can_pay && can('employee_advances.pay')" tooltip="Lunasi Kasbon"
<RowPayAction v-if="employeeAdvance.can_pay && can('employee_advances.pay')" tooltip="Bayar Kasbon"
@click="payConfirmOpen = true" />
<template v-if="canDelete">
@ -107,7 +132,23 @@ function payEmployeeAdvance() {
:description="`Kasbon ${employeeAdvance.amount_formatted} untuk ${employeeAdvance.employee_name} akan dicairkan. Saldo kas akan berkurang.`"
confirm-label="Setujui" cancel-label="Batal" :loading="approveProcessing" @confirm="approveEmployeeAdvance" />
<ConfirmDialog v-if="can('employee_advances.pay')" v-model:open="payConfirmOpen" title="Lunasi kasbon?"
:description="`Pelunasan kasbon ${employeeAdvance.amount_formatted} dari ${employeeAdvance.employee_name}. Saldo kas akan bertambah.`"
confirm-label="Lunasi" cancel-label="Batal" :loading="payProcessing" @confirm="payEmployeeAdvance" />
<ConfirmDialog v-if="can('employee_advances.pay')" v-model:open="payConfirmOpen"
:title="employeeAdvance.remaining_amount < employeeAdvance.amount ? 'Bayar sisa kasbon?' : 'Bayar kasbon?'"
:description="`Pembayaran kasbon ${employeeAdvance.employee_name}. Sisa: ${employeeAdvance.remaining_amount_formatted}.`"
confirm-label="Bayar" cancel-label="Batal" :loading="payProcessing" @confirm="payEmployeeAdvance">
<template #content>
<div class="space-y-3 py-2">
<div class="text-sm text-muted-foreground">
<p>Total kasbon: <strong>{{ employeeAdvance.amount_formatted }}</strong></p>
<p>Sudah dibayar: <strong>{{ employeeAdvance.paid_amount_formatted }}</strong></p>
<p>Sisa: <strong class="text-primary">{{ employeeAdvance.remaining_amount_formatted }}</strong></p>
</div>
<Field>
<FieldLabel for="pay-amount" required>Jumlah Pembayaran</FieldLabel>
<RupiahInput id="pay-amount" v-model="payAmount" placeholder="0" />
<FieldError v-if="payError" :errors="{ amount: [payError] }" />
</Field>
</div>
</template>
</ConfirmDialog>
</template>

View File

@ -36,7 +36,6 @@ const initialData = computed(() => ({
tiktok_order_id: props.order.tiktok_order_id ?? '',
shopee_order_id: props.order.shopee_order_id ?? '',
discount: String(props.order.discount),
shipping_cost: String(props.order.shipping_cost),
notes: props.order.notes ?? '',
items: props.order.items.map((item) => ({
product_variant_id: item.product_variant_id,

View File

@ -10,7 +10,6 @@ import {
Receipt,
Send,
Trash2,
Truck,
User,
X,
} from '@lucide/vue';
@ -143,10 +142,6 @@ const summaryRows = computed(() => {
rows.push({ label: 'Diskon', value: `-${props.order.discount_formatted}` });
}
if (props.order.shipping_cost > 0) {
rows.push({ label: 'Ongkos Kirim', value: props.order.shipping_cost_formatted });
}
return rows;
});
@ -523,25 +518,6 @@ const feeEntries = computed(() => {
</CardContent>
</Card>
<!-- Shipping Info -->
<Card v-if="order.shipping_cost > 0">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Truck class="size-5" />
Pengiriman
</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Ongkos Kirim</span>
<span class="font-medium tabular-nums">
{{ order.shipping_cost_formatted }}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AdminLayout>

View File

@ -74,7 +74,6 @@ const props = defineProps<{
tiktok_order_id: string;
shopee_order_id: string;
discount: string;
shipping_cost: string;
notes: string;
items: OrderCartItem[];
};
@ -95,7 +94,7 @@ const { can } = useCan();
const page = usePage();
const authUser = computed(() => (page.props.auth as Auth).user);
const isMarketingUser = computed(() =>
authUser.value?.roles?.includes('marketing') ?? false,
(authUser.value?.roles?.includes('marketing-offline') || authUser.value?.roles?.includes('marketing-online')) ?? false,
);
const search = ref('');
@ -126,7 +125,6 @@ const form = useForm({
tiktok_order_id: '',
shopee_order_id: '',
discount: '',
shipping_cost: '',
notes: '',
});
@ -147,7 +145,6 @@ function populateForm() {
form.tiktok_order_id = props.initialData.tiktok_order_id;
form.shopee_order_id = props.initialData.shopee_order_id;
form.discount = props.initialData.discount;
form.shipping_cost = String(props.initialData.shipping_cost);
form.notes = props.initialData.notes;
cart.value = props.initialData.items.map((item) => ({
...item,
@ -253,8 +250,7 @@ const subtotal = computed(() =>
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const shippingAmount = computed(() => Number(parseRupiah(form.shipping_cost)) || 0);
const totalAmount = computed(() => Math.max(subtotal.value - discountAmount.value + shippingAmount.value, 0));
const totalAmount = computed(() => Math.max(subtotal.value - discountAmount.value, 0));
function getVariantPrice(variant: ProductVariantItem): ProductPriceItem | undefined {
return variant.prices.find((price) => price.type === form.price_type);
@ -460,7 +456,6 @@ function buildFormData(): FormData {
}
formData.append('discount', parseRupiah(form.discount));
formData.append('shipping_cost', parseRupiah(form.shipping_cost));
formData.append('notes', form.notes);
if (isCreateMode.value && printAfterSave.value) {
@ -822,11 +817,6 @@ function submit() {
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field v-if="isStoreChannel">
<FieldLabel for="shipping_cost">Ongkir</FieldLabel>
<RupiahInput id="shipping_cost" v-model="form.shipping_cost" placeholder="0" />
<FieldError :errors="formErrors(form, 'shipping_cost')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>

View File

@ -126,8 +126,6 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
}}</strong></span>
<span>Diskon <strong class="text-primary">{{ order.discount_formatted
}}</strong></span>
<span v-if="order.shipping_cost > 0">Ongkir <strong class="text-primary">{{
order.shipping_cost_formatted }}</strong></span>
<span>Total <strong class="text-primary">{{ order.total_amount_formatted
}}</strong></span>
<span v-if="order.marketplace_settings_snapshot?.total_fee_amount" class="text-destructive">

View File

@ -12,7 +12,7 @@ const props = defineProps<{
const { can } = useCan();
const isSystemRole = computed(() => {
return ['developer', 'owner', 'admin-toko', 'admin-bahan-baku', 'direktur', 'marketing', 'cashier', 'non-operator'].includes(props.role.name);
return ['developer', 'owner', 'admin-toko', 'admin-bahan-baku', 'direktur', 'marketing-offline', 'marketing-online', 'cashier', 'non-operator'].includes(props.role.name);
});
</script>

View File

@ -3,6 +3,10 @@ export type EmployeeAdvanceListItem = {
employee_id: number;
amount: number;
amount_formatted: string;
paid_amount: number;
paid_amount_formatted: string;
remaining_amount: number;
remaining_amount_formatted: string;
description: string;
due_date_input: string;
due_date_formatted: string;

View File

@ -52,8 +52,6 @@ export type OrderListItem = {
shopee_order_id: string | null;
subtotal_formatted: string;
discount_formatted: string;
shipping_cost: number;
shipping_cost_formatted: string;
total_amount: number;
total_amount_formatted: string;
notes: string | null;
@ -116,7 +114,6 @@ export type OrderEditItem = {
tiktok_order_id: string | null;
shopee_order_id: string | null;
discount: number;
shipping_cost: number;
notes: string | null;
items: Array<{
product_variant_id: number;
@ -151,8 +148,6 @@ export type OrderDetail = {
subtotal_formatted: string;
discount: number;
discount_formatted: string;
shipping_cost: number;
shipping_cost_formatted: string;
total_amount: number;
total_amount_formatted: string;
notes: string | null;