feat: enhance dashboard metrics by adding attendance, cash overview, revenue summary, and expense summary

This commit is contained in:
Yoga Pangestu 2026-06-23 15:35:37 +07:00
parent 993a87cc66
commit 1dc274cce1
3 changed files with 326 additions and 1130 deletions

View File

@ -4,7 +4,6 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\System\DashboardService; use App\Services\System\DashboardService;
use Carbon\Carbon;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -16,33 +15,17 @@ public function __construct(
public function index(): Response public function index(): Response
{ {
$now = Carbon::now();
$startOfMonth = $now->copy()->startOfMonth();
$endOfMonth = $now->copy()->endOfMonth();
$startOfDay = Carbon::today()->startOfDay();
$endOfDay = Carbon::today()->endOfDay();
return Inertia::render('admin/Dashboard', [ return Inertia::render('admin/Dashboard', [
'attendance' => $this->dashboardService->getAttendance(),
'cashOverview' => $this->dashboardService->getCashOverview(),
'revenueSummary' => $this->dashboardService->getRevenueSummary(),
'expenseSummary' => $this->dashboardService->getExpenseSummary(),
'topSuppliers' => $this->dashboardService->getTopSuppliers(), 'topSuppliers' => $this->dashboardService->getTopSuppliers(),
'topCustomers' => $this->dashboardService->getTopCustomers(), 'topCustomers' => $this->dashboardService->getTopCustomers(),
'topProducts' => $this->dashboardService->getTopProducts(), 'topProducts' => $this->dashboardService->getTopProducts(),
'purchaseSummary' => $this->dashboardService->getPurchaseSummary($startOfDay, $endOfDay),
'cuttingSummary' => $this->dashboardService->getCuttingSummary(),
'cuttingByStatus' => $this->dashboardService->getCuttingByStatus(),
'orderStats' => $this->dashboardService->getOrderStats(), 'orderStats' => $this->dashboardService->getOrderStats(),
'revenueSummary' => $this->dashboardService->getRevenueSummary($startOfDay, $endOfDay),
'marketplaceSummary' => $this->dashboardService->getMarketplaceSummary(),
'cashAccounts' => $this->dashboardService->getCashAccounts(),
'cashSummary' => $this->dashboardService->getCashSummary($startOfDay, $endOfDay),
'monthlyCashFlow' => $this->dashboardService->getMonthlyCashFlow(),
'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startOfDay, $endOfDay),
'kasbonSummary' => $this->dashboardService->getKasbonSummary($startOfDay, $endOfDay),
'payrollSummary' => $this->dashboardService->getPayrollSummary($startOfMonth, $endOfMonth),
'employeeSummary' => $this->dashboardService->getEmployeeSummary(),
'attendanceToday' => $this->dashboardService->getAttendanceToday($startOfDay, $endOfDay),
'monthlyRevenueTrend' => $this->dashboardService->getMonthlyRevenueTrend(),
'monthlyPurchaseTrend' => $this->dashboardService->getMonthlyPurchaseTrend(),
]); ]);
} }
} }

View File

@ -2,119 +2,133 @@
namespace App\Services\System; namespace App\Services\System;
use App\Enums\CuttingStatus;
use App\Enums\EmploymentStatus;
use App\Enums\OrderChannel; use App\Enums\OrderChannel;
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\Category;
use App\Models\Cutting;
use App\Models\CuttingResult;
use App\Models\Employee; use App\Models\Employee;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\Expense; use App\Models\Expense;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Order; use App\Models\Order;
use App\Models\OrderItem; use App\Models\OrderItem;
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\RawMaterialPrice;
use Carbon\Carbon; use Carbon\Carbon;
class DashboardService class DashboardService
{ {
public function getRawMaterialStock(): array public function getAttendance(): array
{ {
$stockSummary = RawMaterialPrice::query() $totalEmployees = Employee::query()->count();
->selectRaw('SUM(stock) as total_stock, SUM(stock * price) as total_value') $today = Carbon::today();
->first();
$stockByUnit = RawMaterialPrice::query() $present = Attendance::query()
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') ->where('attendance_date', $today)
->selectRaw('raw_materials.unit, SUM(raw_material_prices.stock) as total_stock') ->distinct('employee_id')
->groupBy('raw_materials.unit') ->count('employee_id');
->get()
->mapWithKeys(fn ($item) => [ $onLeave = LeaveRequest::query()
$item->unit => (float) $item->total_stock, ->approved()
]) ->where('start_date', '<=', $today)
->toArray(); ->where('end_date', '>=', $today)
->distinct('employee_id')
->count('employee_id');
$absent = max(0, $totalEmployees - $present - $onLeave);
return [ return [
'total_stock' => (float) ($stockSummary->total_stock ?? 0), 'total_employees' => $totalEmployees,
'total_value' => (int) ($stockSummary->total_value ?? 0), 'percentage' => $totalEmployees > 0 ? round($present / $totalEmployees * 100) : 0,
'by_unit' => $stockByUnit, 'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
]; ];
} }
public function getProductStock(): array public function getCashOverview(): array
{ {
$variantSummary = ProductVariant::query() $totalBalance = CashAccount::query()->sum('balance');
->selectRaw('SUM(stock) as total_stock, SUM(reject_stock) as total_reject, COUNT(*) as total_variants')
$summary = CashTransaction::query()
->whereDate('created_at', Carbon::today())
->selectRaw("
COUNT(*) as total_transactions,
COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as total_deposit,
COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as total_withdrawal
")
->first(); ->first();
$totalStock = (int) ($variantSummary->total_stock ?? 0);
$totalValue = Cutting::query()
->whereNotNull('cost_per_unit')
->join('cutting_results', 'cuttings.id', '=', 'cutting_results.cutting_id')
->selectRaw('SUM(cutting_results.warehouse_stock * cuttings.cost_per_unit) as total_value')
->value('total_value');
$totalProducts = Product::query()->count();
$totalCategories = Category::query()->count();
return [ return [
'total_stock' => $totalStock, 'total_balance' => (int) $totalBalance,
'total_reject' => (int) ($variantSummary->total_reject ?? 0), 'total_transactions' => (int) ($summary->total_transactions ?? 0),
'total_value' => (int) ($totalValue ?? 0), 'total_deposit' => (int) ($summary->total_deposit ?? 0),
'total_variants' => (int) ($variantSummary->total_variants ?? 0), 'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0),
'total_products' => $totalProducts,
'total_categories' => $totalCategories,
]; ];
} }
public function getLowStockProducts(): array public function getRevenueSummary(): array
{ {
return ProductVariant::query() $revenueSummary = Order::query()
->where('stock', '<=', ProductVariant::minStock()) ->completed()
->join('products', 'product_variants.product_id', '=', 'products.id') ->whereDate('created_at', Carbon::today())
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock") ->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')
->orderBy('stock') ->first();
->limit(5)
$totalMarketplaceFees = Order::query()
->completed()
->whereNotNull('marketplace_settings_snapshot')
->whereDate('created_at', Carbon::today())
->get() ->get()
->map(fn ($item) => [ ->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0));
'name' => $item->full_name,
'stock' => (int) $item->stock, return [
]) 'total_revenue' => (int) ($revenueSummary->total_revenue ?? 0),
->toArray(); '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),
];
} }
public function getLowStockMaterials(): array public function getExpenseSummary(): array
{ {
return RawMaterialPrice::query() $today = Carbon::today();
->where('stock', '<=', 5)
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id') $purchase = Purchase::query()
->selectRaw("CONCAT(raw_materials.name, ' - ', raw_material_prices.variant) as full_name, raw_material_prices.stock, raw_materials.unit") ->whereDate('created_at', $today)
->orderBy('stock') ->selectRaw('COALESCE(SUM(total), 0) as total')
->limit(5) ->first();
->get()
->map(fn ($item) => [ $expenses = Expense::query()
'name' => $item->full_name, ->whereDate('created_at', $today)
'stock' => (float) $item->stock, ->selectRaw('COALESCE(SUM(amount), 0) as total')
'unit' => $item->unit, ->first();
])
->toArray(); $employeeAdvance = EmployeeAdvance::query()
->whereDate('created_at', $today)
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
$purchaseTotal = (int) ($purchase->total ?? 0);
$expenseTotal = (int) ($expenses->total ?? 0);
$advanceTotal = (int) ($employeeAdvance->total ?? 0);
return [
'total' => $purchaseTotal + $expenseTotal + $advanceTotal,
'purchase_total' => $purchaseTotal,
'expense_total' => $expenseTotal,
'advance_total' => $advanceTotal,
];
} }
public function getTopSuppliers(): array public function getTopSuppliers(): array
{ {
return Purchase::query() return Purchase::query()
->whereDate('purchases.created_at', Carbon::today())
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id') ->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count') ->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count')
->groupBy('suppliers.id', 'suppliers.name') ->groupBy('suppliers.id', 'suppliers.name')
@ -133,6 +147,7 @@ public function getTopCustomers(): array
{ {
return Order::query() return Order::query()
->completed() ->completed()
->whereDate('orders.created_at', Carbon::today())
->join('customers', 'orders.customer_id', '=', 'customers.id') ->join('customers', 'orders.customer_id', '=', 'customers.id')
->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count') ->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count')
->groupBy('customers.id', 'customers.name') ->groupBy('customers.id', 'customers.name')
@ -154,6 +169,7 @@ public function getTopProducts(): array
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id') ->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id') ->join('products', 'product_variants.product_id', '=', 'products.id')
->where('orders.status', OrderStatus::COMPLETED) ->where('orders.status', OrderStatus::COMPLETED)
->whereDate('orders.created_at', Carbon::today())
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue") ->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') ->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name')
->orderByDesc('total_qty') ->orderByDesc('total_qty')
@ -167,57 +183,10 @@ public function getTopProducts(): array
->toArray(); ->toArray();
} }
public function getPurchaseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = Purchase::query();
if ($startDate && $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
}
$purchaseSummary = $query->selectRaw('COUNT(*) as total_purchases, SUM(total) as total_spent, SUM(discount) as total_discount')
->first();
return [
'total_purchases' => (int) ($purchaseSummary->total_purchases ?? 0),
'total_spent' => (int) ($purchaseSummary->total_spent ?? 0),
'total_discount' => (int) ($purchaseSummary->total_discount ?? 0),
];
}
public function getCuttingSummary(): array
{
$totalCost = Cutting::query()
->selectRaw('COALESCE(SUM(total_material_cost), 0) + COALESCE(SUM(sewing_cost), 0) + COALESCE(SUM(other_cost), 0) as total_cost')
->value('total_cost');
$totalResults = CuttingResult::query()
->selectRaw('SUM(cutting_result) as total_cutting, SUM(warehouse_stock) as total_warehouse, SUM(cutting_reject) as total_reject')
->first();
return [
'total_cost' => (int) ($totalCost ?? 0),
'total_cutting' => (int) ($totalResults->total_cutting ?? 0),
'total_warehouse' => (int) ($totalResults->total_warehouse ?? 0),
'total_reject' => (int) ($totalResults->total_reject ?? 0),
];
}
public function getCuttingByStatus(): array
{
return Cutting::query()
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->get()
->map(fn ($item) => [
'status' => $item->status instanceof CuttingStatus ? $item->status->value : $item->status,
'label' => $item->status instanceof CuttingStatus ? $item->status->label() : CuttingStatus::from($item->status)->label(),
'count' => (int) $item->count,
])
->toArray();
}
public function getOrderStats(): array public function getOrderStats(): array
{ {
$byChannel = Order::query() $byChannel = Order::query()
->whereDate('created_at', Carbon::today())
->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total') ->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total')
->groupBy('channel') ->groupBy('channel')
->get() ->get()
@ -229,6 +198,7 @@ public function getOrderStats(): array
]); ]);
$byPaymentType = Order::query() $byPaymentType = Order::query()
->whereDate('created_at', Carbon::today())
->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total') ->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total')
->groupBy('payment_type') ->groupBy('payment_type')
->get() ->get()
@ -241,6 +211,7 @@ public function getOrderStats(): array
$byMarketing = Order::query() $byMarketing = Order::query()
->whereNotNull('marketing_id') ->whereNotNull('marketing_id')
->whereDate('orders.created_at', Carbon::today())
->join('users', 'orders.marketing_id', '=', 'users.id') ->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id') ->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
->selectRaw('COALESCE(user_profiles.full_name, users.username) as name, COUNT(*) as count, SUM(orders.total_amount) as total') ->selectRaw('COALESCE(user_profiles.full_name, users.username) as name, COUNT(*) as count, SUM(orders.total_amount) as total')
@ -255,6 +226,7 @@ public function getOrderStats(): array
]); ]);
$byStatus = Order::query() $byStatus = Order::query()
->whereDate('created_at', Carbon::today())
->selectRaw('status, COUNT(*) as count') ->selectRaw('status, COUNT(*) as count')
->groupBy('status') ->groupBy('status')
->get() ->get()
@ -271,375 +243,4 @@ public function getOrderStats(): array
'by_status' => $byStatus->toArray(), 'by_status' => $byStatus->toArray(),
]; ];
} }
public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = Order::query()->completed();
if ($startDate && $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
}
$revenueSummary = $query->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')
->first();
$feesQuery = Order::query()->completed()->whereNotNull('marketplace_settings_snapshot');
if ($startDate && $endDate) {
$feesQuery->whereBetween('created_at', [$startDate, $endDate]);
}
$totalMarketplaceFees = $feesQuery->get()
->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0));
return [
'total_revenue' => (int) ($revenueSummary->total_revenue ?? 0),
'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),
];
}
public function getMarketplaceSummary(): array
{
$marketplaceOrders = Order::query()
->completed()
->whereIn('channel', [OrderChannel::SHOPEE, OrderChannel::TIKTOK])
->whereNotNull('marketplace_settings_snapshot')
->get();
$totalRevenue = $marketplaceOrders->sum('total_amount');
$totalFees = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0));
$totalNet = $marketplaceOrders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['net_amount'] ?? 0));
$totalOrders = $marketplaceOrders->count();
return [
'total_revenue' => (int) $totalRevenue,
'total_fees' => (int) $totalFees,
'total_net' => (int) $totalNet,
'total_orders' => (int) $totalOrders,
];
}
public function getCashAccounts(): array
{
return CashAccount::query()
->selectRaw('name, balance')
->get()
->map(fn ($item) => [
'name' => $item->name,
'balance' => (int) $item->balance,
])
->toArray();
}
public function getCashSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = CashTransaction::query();
if ($startDate && $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
}
$summary = $query->selectRaw("
COUNT(*) as total_transactions,
COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as total_deposit,
COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as total_withdrawal
")
->first();
return [
'total_transactions' => (int) ($summary->total_transactions ?? 0),
'total_deposit' => (int) ($summary->total_deposit ?? 0),
'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0),
];
}
public function getMonthlyCashFlow(): array
{
$months = collect();
for ($monthOffset = 5; $monthOffset >= 0; $monthOffset--) {
$date = Carbon::now()->subMonths($monthOffset);
$months->push([
'year' => $date->year,
'month' => $date->month,
'label' => $date->translatedFormat('M Y'),
]);
}
$result = $months->map(function ($month) {
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
$end = $start->copy()->endOfMonth();
$monthlyTransactions = CashTransaction::query()
->whereBetween('created_at', [$start, $end])
->selectRaw("
COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as deposits,
COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as withdrawals
")
->first();
return [
'label' => $month['label'],
'deposits' => (int) $monthlyTransactions->deposits,
'withdrawals' => (int) $monthlyTransactions->withdrawals,
];
});
return $result->toArray();
}
public function getMonthlyExpenses(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = Expense::query();
if ($startDate && $endDate) {
$query->whereBetween('created_at', [$startDate, $endDate]);
}
$monthlyExpenses = $query->selectRaw('COALESCE(SUM(amount), 0) as total, COUNT(*) as count')
->first();
return [
'total' => (int) ($monthlyExpenses->total ?? 0),
'count' => (int) ($monthlyExpenses->count ?? 0),
];
}
public function getKasbonSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$pendingQuery = EmployeeAdvance::query()->pending();
$approvedQuery = EmployeeAdvance::query()->approved();
$paidQuery = EmployeeAdvance::query()->paid();
$employeeQuery = EmployeeAdvance::query();
if ($startDate && $endDate) {
$pendingQuery->whereBetween('created_at', [$startDate, $endDate]);
$approvedQuery->whereBetween('created_at', [$startDate, $endDate]);
$paidQuery->whereBetween('created_at', [$startDate, $endDate]);
$employeeQuery->whereBetween('created_at', [$startDate, $endDate]);
}
$pending = $pendingQuery->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')->first();
$approved = $approvedQuery->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')->first();
$paid = $paidQuery->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')->first();
$totalEmployees = $employeeQuery->distinct('employee_id')->count('employee_id');
return [
'pending' => [
'count' => (int) ($pending->count ?? 0),
'total' => (int) ($pending->total ?? 0),
],
'approved' => [
'count' => (int) ($approved->count ?? 0),
'total' => (int) ($approved->total ?? 0),
],
'paid' => [
'count' => (int) ($paid->count ?? 0),
'total' => (int) ($paid->total ?? 0),
],
'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (int) ($paid->total ?? 0),
'total_employees' => $totalEmployees,
];
}
public function getPayrollSummary(Carbon $startOfMonth, Carbon $endOfMonth): array
{
$period = PayrollPeriod::query()
->where('year', $startOfMonth->year)
->where('month', $startOfMonth->month)
->first();
if (! $period) {
return [
'has_period' => false,
'total_employees' => 0,
'total_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'paid_count' => 0,
'unpaid_count' => 0,
];
}
$payrolls = Payroll::query()
->where('payroll_period_id', $period->id)
->selectRaw("
COUNT(*) as total_employees,
COALESCE(SUM(total_amount), 0) as total_amount,
COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) as paid_amount,
COALESCE(SUM(CASE WHEN status = 'unpaid' THEN total_amount ELSE 0 END), 0) as unpaid_amount,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count,
SUM(CASE WHEN status = 'unpaid' THEN 1 ELSE 0 END) as unpaid_count
")
->first();
return [
'has_period' => true,
'period_status' => $period->status->value,
'total_employees' => (int) ($payrolls->total_employees ?? 0),
'total_amount' => (int) ($payrolls->total_amount ?? 0),
'paid_amount' => (int) ($payrolls->paid_amount ?? 0),
'unpaid_amount' => (int) ($payrolls->unpaid_amount ?? 0),
'paid_count' => (int) ($payrolls->paid_count ?? 0),
'unpaid_count' => (int) ($payrolls->unpaid_count ?? 0),
];
}
public function getLeaveRequestSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$query = LeaveRequest::query();
if ($startDate && $endDate) {
$query->where(function ($query) use ($startDate, $endDate) {
$query->whereBetween('start_date', [$startDate, $endDate])
->orWhereBetween('end_date', [$startDate, $endDate]);
});
}
$leaveRequestSummary = $query->selectRaw("
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count
")
->first();
return [
'total' => (int) ($leaveRequestSummary->total ?? 0),
'pending' => (int) ($leaveRequestSummary->pending_count ?? 0),
'approved' => (int) ($leaveRequestSummary->approved_count ?? 0),
'rejected' => (int) ($leaveRequestSummary->rejected_count ?? 0),
];
}
public function getEmployeeSummary(): array
{
$total = Employee::query()->count();
$byStatus = Employee::query()
->selectRaw('employment_status, COUNT(*) as count')
->groupBy('employment_status')
->get()
->map(fn ($item) => [
'status' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->value : $item->employment_status,
'label' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->label() : EmploymentStatus::from($item->employment_status)->label(),
'count' => (int) $item->count,
]);
return [
'total' => $total,
'by_status' => $byStatus->toArray(),
];
}
public function getAttendanceToday(?Carbon $startDate = null, ?Carbon $endDate = null): array
{
$totalEmployees = Employee::query()->count();
if ($startDate && $endDate) {
$present = Attendance::query()
->whereBetween('attendance_date', [$startDate, $endDate])
->count();
$onLeave = LeaveRequest::query()
->approved()
->where(function ($query) use ($startDate, $endDate) {
$query->whereBetween('start_date', [$startDate, $endDate])
->orWhereBetween('end_date', [$startDate, $endDate]);
})
->count();
$days = (int) $startDate->copy()->startOfDay()->diffInDays($endDate->copy()->startOfDay()) + 1;
$totalPossible = $totalEmployees * $days;
$absent = max(0, $totalPossible - $present - $onLeave);
return [
'total_employees' => (int) $totalPossible,
'present' => (int) $present,
'absent' => (int) $absent,
'on_leave' => (int) $onLeave,
];
} else {
$today = Carbon::today();
$present = Attendance::query()
->where('attendance_date', $today)
->distinct('employee_id')
->count('employee_id');
$onLeave = LeaveRequest::query()
->approved()
->where('start_date', '<=', $today)
->where('end_date', '>=', $today)
->distinct('employee_id')
->count('employee_id');
$notPresent = max(0, $totalEmployees - $present);
$absent = max(0, $notPresent - $onLeave);
return [
'total_employees' => $totalEmployees,
'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
];
}
}
public function getMonthlyRevenueTrend(): array
{
$months = collect();
for ($monthOffset = 5; $monthOffset >= 0; $monthOffset--) {
$date = Carbon::now()->subMonths($monthOffset);
$months->push([
'year' => $date->year,
'month' => $date->month,
'label' => $date->translatedFormat('M Y'),
]);
}
$result = $months->map(function ($month) {
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
$end = $start->copy()->endOfMonth();
$monthlyRevenue = Order::query()
->completed()
->whereBetween('created_at', [$start, $end])
->selectRaw('COALESCE(SUM(total_amount), 0) as total, COUNT(*) as count')
->first();
return [
'label' => $month['label'],
'total' => (int) $monthlyRevenue->total,
'count' => (int) $monthlyRevenue->count,
];
});
return $result->toArray();
}
public function getMonthlyPurchaseTrend(): array
{
$months = collect();
for ($monthOffset = 5; $monthOffset >= 0; $monthOffset--) {
$date = Carbon::now()->subMonths($monthOffset);
$months->push([
'year' => $date->year,
'month' => $date->month,
'label' => $date->translatedFormat('M Y'),
]);
}
$result = $months->map(function ($month) {
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
$end = $start->copy()->endOfMonth();
$monthlyPurchases = Purchase::query()
->whereBetween('created_at', [$start, $end])
->selectRaw('COALESCE(SUM(total), 0) as total, COUNT(*) as count')
->first();
return [
'label' => $month['label'],
'total' => (int) $monthlyPurchases->total,
'count' => (int) $monthlyPurchases->count,
];
});
return $result->toArray();
}
} }

View File

@ -1,4 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import {
Banknote,
Clock,
Moon,
Sun,
Sunrise,
TrendingDown,
TrendingUp,
UserCheck,
} from '@lucide/vue';
import { VisAxis, VisDonut, VisGroupedBar, VisXYContainer } from '@unovis/vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import StatCard from '@/components/card/StatCard.vue'; import StatCard from '@/components/card/StatCard.vue';
import { import {
Card, Card,
@ -17,21 +30,40 @@ import {
componentToString, componentToString,
} from '@/components/ui/chart'; } from '@/components/ui/chart';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { Head } from '@inertiajs/vue3'; import { formatRupiah } from '@/lib/rupiah';
import {
Banknote,
Clock,
Moon,
Sun,
Sunrise,
TrendingDown,
TrendingUp,
UserCheck,
} from '@lucide/vue';
import { VisAxis, VisDonut, VisGroupedBar, VisXYContainer } from '@unovis/vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
interface DashboardProps { interface DashboardProps {
// Row 1: Stat Cards
attendance: {
total_employees: number;
percentage: number;
present: number;
absent: number;
on_leave: number;
};
cashOverview: {
total_balance: number;
total_transactions: number;
total_deposit: number;
total_withdrawal: number;
};
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_potongan: number;
total_shipping: number;
total_orders: number;
avg_order: number;
};
expenseSummary: {
total: number;
purchase_total: number;
expense_total: number;
advance_total: number;
};
// Row 2: Top 5 Charts
topSuppliers: Array<{ topSuppliers: Array<{
name: string; name: string;
total_amount: number; total_amount: number;
@ -47,22 +79,8 @@ interface DashboardProps {
total_qty: number; total_qty: number;
total_revenue: number; total_revenue: number;
}>; }>;
purchaseSummary: {
total_purchases: number; // Row 3: Order Stats
total_spent: number;
total_discount: number;
};
cuttingSummary: {
total_cost: number;
total_cutting: number;
total_warehouse: number;
total_reject: number;
};
cuttingByStatus: Array<{
status: string;
label: string;
count: number;
}>;
orderStats: { orderStats: {
by_channel: Array<{ by_channel: Array<{
channel: string; channel: string;
@ -87,80 +105,6 @@ interface DashboardProps {
count: number; count: number;
}>; }>;
}; };
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_potongan: number;
total_shipping: number;
total_orders: number;
avg_order: number;
};
marketplaceSummary: {
total_revenue: number;
total_fees: number;
total_net: number;
total_orders: number;
};
cashAccounts: Array<{
name: string;
balance: number;
}>;
cashSummary: {
total_transactions: number;
total_deposit: number;
total_withdrawal: number;
};
monthlyCashFlow: Array<{
label: string;
deposits: number;
withdrawals: number;
}>;
monthlyExpenses: {
total: number;
count: number;
};
kasbonSummary: {
pending: { count: number; total: number };
approved: { count: number; total: number };
paid: { count: number; total: number };
total: number;
total_employees: number;
};
payrollSummary: {
has_period: boolean;
period_status?: string;
total_employees: number;
total_amount: number;
paid_amount: number;
unpaid_amount: number;
paid_count: number;
unpaid_count: number;
};
employeeSummary: {
total: number;
by_status: Array<{
status: string;
label: string;
count: number;
}>;
};
attendanceToday: {
total_employees: number;
present: number;
absent: number;
on_leave: number;
};
monthlyRevenueTrend: Array<{
label: string;
total: number;
count: number;
}>;
monthlyPurchaseTrend: Array<{
label: string;
total: number;
count: number;
}>;
} }
const props = defineProps<DashboardProps>(); const props = defineProps<DashboardProps>();
@ -214,10 +158,6 @@ const greeting = computed(() => {
return { text: 'Selamat Malam', icon: Moon }; return { text: 'Selamat Malam', icon: Moon };
}); });
function formatRupiah(value: number): string {
return 'Rp ' + value.toLocaleString('id-ID');
}
// Chart configs // Chart configs
const supplierChartConfig = { const supplierChartConfig = {
amount: { amount: {
@ -332,31 +272,21 @@ const productBarData = computed<ProductData[]>(() => {
qty: p.total_qty, qty: p.total_qty,
})); }));
}); });
const totalCashBalance = computed(() => {
return props.cashAccounts.reduce((sum, acc) => sum + acc.balance, 0);
});
</script> </script>
<template> <template>
<Head title="Dashboard" />
<Head title="Dasbor" />
<AdminLayout> <AdminLayout>
<div class="flex flex-1 flex-col gap-6"> <div class="flex flex-1 flex-col gap-6">
<!-- Welcome Card --> <!-- Welcome Card -->
<Card class="relative overflow-hidden"> <Card class="relative overflow-hidden">
<div <div class="absolute inset-0 bg-linear-to-br from-primary/5 to-background" />
class="absolute inset-0 bg-linear-to-br from-primary/5 to-background"
/>
<CardHeader class="relative"> <CardHeader class="relative">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div class="flex size-12 items-center justify-center rounded-full bg-primary/10">
class="flex size-12 items-center justify-center rounded-full bg-primary/10" <component :is="greeting.icon" class="size-6 text-primary" />
>
<component
:is="greeting.icon"
class="size-6 text-primary"
/>
</div> </div>
<div> <div>
<CardTitle class="text-2xl font-bold"> <CardTitle class="text-2xl font-bold">
@ -370,9 +300,7 @@ const totalCashBalance = computed(() => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="relative"> <CardContent class="relative">
<div <div class="flex items-center gap-2 text-sm text-muted-foreground">
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<span>{{ dateStr }}</span> <span>{{ dateStr }}</span>
<span>-</span> <span>-</span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
@ -383,252 +311,143 @@ const totalCashBalance = computed(() => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Row 1: Stock & Finance Summary -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Attendance Today --> <StatCard title="Kehadiran" :icon="UserCheck" main-label="Total Karyawan"
<StatCard :main-value="attendance.total_employees" :sub-label="attendance.percentage + '% hadir'" :items="[
title="Kehadiran"
:icon="UserCheck"
main-label="Total Karyawan"
:main-value="attendanceToday.total_employees"
:sub-label="
(attendanceToday.total_employees > 0
? Math.round(
(attendanceToday.present /
attendanceToday.total_employees) *
100,
)
: 0) + '% hadir'
"
:items="[
{ {
label: 'Hadir', label: 'Hadir',
value: attendanceToday.present, value: attendance.present,
}, },
{ {
label: 'Tidak Hadir', label: 'Tidak Hadir',
value: attendanceToday.absent, value: attendance.absent,
}, },
{ {
label: 'Cuti', label: 'Cuti',
value: attendanceToday.on_leave ?? 0, value: attendance.on_leave,
}, },
]" ]" />
/>
<!-- Cash Accounts --> <StatCard title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
<StatCard :main-value="'Rp' + formatRupiah(cashOverview.total_balance)" :sub-label="cashOverview.total_transactions + ' transaksi'
title="Kas Toko" " :items="[
:icon="Banknote"
main-label="Total Saldo"
:main-value="formatRupiah(totalCashBalance)"
:sub-label="
cashSummary.total_transactions + ' transaksi hari ini'
"
:items="[
{ {
label: 'Deposit', label: 'Deposit',
value: formatRupiah(cashSummary.total_deposit), value: 'Rp' + formatRupiah(cashOverview.total_deposit),
}, },
{ {
label: 'Withdrawal', label: 'Withdrawal',
value: formatRupiah(cashSummary.total_withdrawal), value: 'Rp' + formatRupiah(cashOverview.total_withdrawal),
}, },
]" ]" :cols="2" />
:cols="2"
/>
<StatCard <StatCard title="Pendapatan" :icon="TrendingUp" main-label="Total"
title="Total Pengeluaran" :main-value="'Rp' + formatRupiah(revenueSummary.total_revenue)" :sub-label="revenueSummary.total_orders + ' transaksi selesai'
:icon="TrendingDown" " :items="[
main-label="Total"
:main-value="
formatRupiah(
purchaseSummary.total_spent +
monthlyExpenses.total +
kasbonSummary.total,
)
"
:items="[
{
label: 'Belanja',
value: formatRupiah(purchaseSummary.total_spent),
},
{
label: 'Pengeluaran',
value: formatRupiah(monthlyExpenses.total),
},
{
label: 'Kasbon',
value: formatRupiah(kasbonSummary.total),
},
]"
/>
<!-- Total Pendapatan -->
<StatCard
title="Total Pendapatan"
:icon="TrendingUp"
main-label="Total"
:main-value="formatRupiah(revenueSummary.total_revenue)"
:sub-label="
revenueSummary.total_orders + ' transaksi selesai'
"
:items="[
{ {
label: 'Bersih', label: 'Bersih',
value: formatRupiah( value: 'Rp' + formatRupiah(
revenueSummary.total_revenue - revenueSummary.total_revenue -
revenueSummary.total_potongan, revenueSummary.total_potongan,
), ),
}, },
{ {
label: 'Potongan', label: 'Potongan',
value: formatRupiah(revenueSummary.total_potongan), value: 'Rp' + formatRupiah(revenueSummary.total_potongan),
}, },
{ {
label: 'Diskon', label: 'Diskon',
value: formatRupiah(revenueSummary.total_discount), value: 'Rp' + formatRupiah(revenueSummary.total_discount),
}, },
]" ]" />
/>
<StatCard title="Pengeluaran" :icon="TrendingDown" main-label="Total"
:main-value="'Rp' + formatRupiah(expenseSummary.total)" :items="[
{
label: 'Belanja',
value: 'Rp' + formatRupiah(expenseSummary.purchase_total),
},
{
label: 'Pengeluaran',
value: 'Rp' + formatRupiah(expenseSummary.expense_total),
},
{
label: 'Kasbon',
value: 'Rp' + formatRupiah(expenseSummary.advance_total),
},
]" />
</div> </div>
<!-- Row 3: Top 5 Charts -->
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<!-- Top 5 Suppliers Chart -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Top 5 Supplier</CardTitle> <CardTitle class="text-base">Top 5 Supplier</CardTitle>
<CardDescription <CardDescription>Berdasarkan total harga pembelian</CardDescription>
>Berdasarkan total harga pembelian</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer <ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
v-if="supplierBarData.length > 0" class="min-h-[250px] w-full">
:config="supplierChartConfig"
class="min-h-[250px] w-full"
>
<VisXYContainer :data="supplierBarData"> <VisXYContainer :data="supplierBarData">
<VisGroupedBar <VisGroupedBar :x="(d: SupplierData) => d.name" :y="(d: SupplierData) => d.amount"
:x="(d: SupplierData) => d.name" :color="supplierChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
:y="(d: SupplierData) => d.amount"
:color="supplierChartConfig.amount.color"
:rounded-corners="4"
bar-padding="0.1"
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[250px] items-center justify-center text-muted-foreground"
>
Belum ada data supplier Belum ada data supplier
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Top 5 Customers Chart -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle> <CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
<CardDescription <CardDescription>Berdasarkan total nilai pesanan</CardDescription>
>Berdasarkan total nilai pesanan</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer <ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
v-if="customerBarData.length > 0" class="min-h-[250px] w-full">
:config="customerChartConfig"
class="min-h-[250px] w-full"
>
<VisXYContainer :data="customerBarData"> <VisXYContainer :data="customerBarData">
<VisGroupedBar <VisGroupedBar :x="(d: CustomerData) => d.name" :y="(d: CustomerData) => d.amount"
:x="(d: CustomerData) => d.name" :color="customerChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
:y="(d: CustomerData) => d.amount"
:color="customerChartConfig.amount.color"
:rounded-corners="4"
bar-padding="0.1"
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[250px] items-center justify-center text-muted-foreground"
>
Belum ada data pelanggan Belum ada data pelanggan
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Top 5 Products Chart -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Top 5 Produk</CardTitle> <CardTitle class="text-base">Top 5 Produk</CardTitle>
<CardDescription <CardDescription>Berdasarkan jumlah terjual</CardDescription>
>Berdasarkan jumlah terjual</CardDescription
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer <ChartContainer v-if="productBarData.length > 0" :config="productChartConfig"
v-if="productBarData.length > 0" class="min-h-[250px] w-full">
:config="productChartConfig" <VisXYContainer :data="productBarData" :margin="{ left: -24 }" :y-domain="[0, undefined]">
class="min-h-[250px] w-full" <VisGroupedBar :x="(d: ProductData) => d.name" :y="(d: ProductData) => d.qty"
> :color="productChartConfig.qty.color" :rounded-corners="10" bar-padding="0.1" />
<VisXYContainer <VisAxis type="x" :x="(d: ProductData) => d.name" :tick-line="false"
:data="productBarData" :domain-line="false" :grid-line="false" :num-ticks="productBarData.length"
:margin="{ left: -24 }" :tick-values="productBarData.map((d) => d.name)
:y-domain="[0, undefined]" " />
> <VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
<VisGroupedBar
:x="(d: ProductData) => d.name"
:y="(d: ProductData) => d.qty"
:color="productChartConfig.qty.color"
:rounded-corners="10"
bar-padding="0.1"
/>
<VisAxis
type="x"
:x="(d: ProductData) => d.name"
:tick-line="false"
:domain-line="false"
:grid-line="false"
:num-ticks="productBarData.length"
:tick-values="
productBarData.map((d) => d.name)
"
/>
<VisAxis
type="y"
:num-ticks="3"
:tick-line="false"
:domain-line="false"
/>
<ChartTooltip /> <ChartTooltip />
<ChartCrosshair <ChartCrosshair :template="componentToString(
:template="
componentToString(
productChartConfig, productChartConfig,
ChartTooltipContent, ChartTooltipContent,
{ hideLabel: true }, { hideLabel: true },
) )
" " color="#0000" />
color="#0000"
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[250px] items-center justify-center text-muted-foreground"
>
Belum ada data produk Belum ada data produk
</div> </div>
</CardContent> </CardContent>
<CardFooter class="flex-col items-start gap-2 text-sm"> <CardFooter class="flex-col items-start gap-2 text-sm">
<div <div class="flex gap-2 leading-none font-medium text-muted-foreground">
class="flex gap-2 leading-none font-medium text-muted-foreground"
>
Menampilkan 5 produk dengan penjualan tertinggi Menampilkan 5 produk dengan penjualan tertinggi
<TrendingUp class="h-4 w-4 text-green-500" /> <TrendingUp class="h-4 w-4 text-green-500" />
</div> </div>
@ -636,384 +455,177 @@ const totalCashBalance = computed(() => {
</Card> </Card>
</div> </div>
<!-- Row 4: Order Stats Charts -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Order by Channel -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base" <CardTitle class="text-base">Pesanan per Channel</CardTitle>
>Pesanan per Channel</CardTitle
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="orderStats.by_channel.length > 0"> <div v-if="orderStats.by_channel.length > 0">
<ChartContainer <ChartContainer :config="channelChartConfig" class="min-h-[200px] w-full">
:config="channelChartConfig"
class="min-h-[200px] w-full"
>
<VisXYContainer :data="orderStats.by_channel"> <VisXYContainer :data="orderStats.by_channel">
<VisDonut <VisDonut :value="(
:value="
(
d: (typeof orderStats.by_channel)[number], d: (typeof orderStats.by_channel)[number],
) => d.count ) => d.count
" " :color="orderStats.by_channel.map(
:color="
orderStats.by_channel.map(
(_, i) => (_, i) =>
channelColors[ channelColors[
i % channelColors.length i % channelColors.length
], ],
) )
" " />
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div class="mt-3 flex flex-wrap justify-center gap-3">
class="mt-3 flex flex-wrap justify-center gap-3" <div v-for="(
>
<div
v-for="(
item, index item, index
) in orderStats.by_channel" ) in orderStats.by_channel" :key="item.channel" class="flex items-center gap-1.5">
:key="item.channel" <span class="size-2.5 rounded-full" :style="{
class="flex items-center gap-1.5"
>
<span
class="size-2.5 rounded-full"
:style="{
backgroundColor: backgroundColor:
channelColors[ channelColors[
index % channelColors.length index % channelColors.length
], ],
}" }" />
/> <span class="text-xs text-muted-foreground">{{ item.label }}</span>
<span
class="text-xs text-muted-foreground"
>{{ item.label }}</span
>
<span class="text-xs font-medium">{{ <span class="text-xs font-medium">{{
item.count item.count
}}</span> }}</span>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="flex h-[200px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[200px] items-center justify-center text-muted-foreground"
>
Belum ada data Belum ada data
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Order by Payment Type -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base" <CardTitle class="text-base">Pesanan per Pembayaran</CardTitle>
>Pesanan per Pembayaran</CardTitle
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="orderStats.by_payment_type.length > 0"> <div v-if="orderStats.by_payment_type.length > 0">
<ChartContainer <ChartContainer :config="paymentChartConfig" class="min-h-[200px] w-full">
:config="paymentChartConfig" <VisXYContainer :data="orderStats.by_payment_type">
class="min-h-[200px] w-full" <VisDonut :value="(
>
<VisXYContainer
:data="orderStats.by_payment_type"
>
<VisDonut
:value="
(
d: (typeof orderStats.by_payment_type)[number], d: (typeof orderStats.by_payment_type)[number],
) => d.count ) => d.count
" " :color="orderStats.by_payment_type.map(
:color="
orderStats.by_payment_type.map(
(_, i) => (_, i) =>
paymentColors[ paymentColors[
i % paymentColors.length i % paymentColors.length
], ],
) )
" " />
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div class="mt-3 flex flex-wrap justify-center gap-3">
class="mt-3 flex flex-wrap justify-center gap-3" <div v-for="(
>
<div
v-for="(
item, index item, index
) in orderStats.by_payment_type" ) in orderStats.by_payment_type" :key="item.payment_type"
:key="item.payment_type" class="flex items-center gap-1.5">
class="flex items-center gap-1.5" <span class="size-2.5 rounded-full" :style="{
>
<span
class="size-2.5 rounded-full"
:style="{
backgroundColor: backgroundColor:
paymentColors[ paymentColors[
index % paymentColors.length index % paymentColors.length
], ],
}" }" />
/> <span class="text-xs text-muted-foreground">{{ item.label }}</span>
<span
class="text-xs text-muted-foreground"
>{{ item.label }}</span
>
<span class="text-xs font-medium">{{ <span class="text-xs font-medium">{{
item.count item.count
}}</span> }}</span>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="flex h-[200px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[200px] items-center justify-center text-muted-foreground"
>
Belum ada data Belum ada data
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Order by Marketing -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base" <CardTitle class="text-base">Pesanan per Marketing</CardTitle>
>Pesanan per Marketing</CardTitle
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="orderStats.by_marketing.length > 0"> <div v-if="orderStats.by_marketing.length > 0">
<ChartContainer <ChartContainer :config="marketingChartConfig" class="min-h-[200px] w-full">
:config="marketingChartConfig"
class="min-h-[200px] w-full"
>
<VisXYContainer :data="orderStats.by_marketing"> <VisXYContainer :data="orderStats.by_marketing">
<VisDonut <VisDonut :value="(
:value="
(
d: (typeof orderStats.by_marketing)[number], d: (typeof orderStats.by_marketing)[number],
) => d.count ) => d.count
" " :color="orderStats.by_marketing.map(
:color="
orderStats.by_marketing.map(
(_, i) => (_, i) =>
marketingColors[ marketingColors[
i % i %
marketingColors.length marketingColors.length
], ],
) )
" " />
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div class="mt-3 flex flex-wrap justify-center gap-3">
class="mt-3 flex flex-wrap justify-center gap-3" <div v-for="(
>
<div
v-for="(
item, index item, index
) in orderStats.by_marketing" ) in orderStats.by_marketing" :key="item.name" class="flex items-center gap-1.5">
:key="item.name" <span class="size-2.5 rounded-full" :style="{
class="flex items-center gap-1.5"
>
<span
class="size-2.5 rounded-full"
:style="{
backgroundColor: backgroundColor:
marketingColors[ marketingColors[
index % index %
marketingColors.length marketingColors.length
], ],
}" }" />
/> <span class="text-xs text-muted-foreground">{{ item.name }}</span>
<span
class="text-xs text-muted-foreground"
>{{ item.name }}</span
>
<span class="text-xs font-medium">{{ <span class="text-xs font-medium">{{
item.count item.count
}}</span> }}</span>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="flex h-[200px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[200px] items-center justify-center text-muted-foreground"
>
Belum ada data Belum ada data
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<!-- Order by Status -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base" <CardTitle class="text-base">Pesanan per Status</CardTitle>
>Pesanan per Status</CardTitle
>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="orderStats.by_status.length > 0"> <div v-if="orderStats.by_status.length > 0">
<ChartContainer <ChartContainer :config="statusChartConfig" class="min-h-[200px] w-full">
:config="statusChartConfig"
class="min-h-[200px] w-full"
>
<VisXYContainer :data="orderStats.by_status"> <VisXYContainer :data="orderStats.by_status">
<VisDonut <VisDonut :value="(
:value="
(
d: (typeof orderStats.by_status)[number], d: (typeof orderStats.by_status)[number],
) => d.count ) => d.count
" " :color="orderStats.by_status.map(
:color="
orderStats.by_status.map(
(_, i) => (_, i) =>
statusColors[ statusColors[
i % statusColors.length i % statusColors.length
], ],
) )
" " />
/>
</VisXYContainer> </VisXYContainer>
</ChartContainer> </ChartContainer>
<div <div class="mt-3 flex flex-wrap justify-center gap-3">
class="mt-3 flex flex-wrap justify-center gap-3" <div v-for="(
>
<div
v-for="(
item, index item, index
) in orderStats.by_status" ) in orderStats.by_status" :key="item.status" class="flex items-center gap-1.5">
:key="item.status" <span class="size-2.5 rounded-full" :style="{
class="flex items-center gap-1.5"
>
<span
class="size-2.5 rounded-full"
:style="{
backgroundColor: backgroundColor:
statusColors[ statusColors[
index % statusColors.length index % statusColors.length
], ],
}" }" />
/> <span class="text-xs text-muted-foreground">{{ item.label }}</span>
<span
class="text-xs text-muted-foreground"
>{{ item.label }}</span
>
<span class="text-xs font-medium">{{ <span class="text-xs font-medium">{{
item.count item.count
}}</span> }}</span>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="flex h-[200px] items-center justify-center text-muted-foreground">
v-else
class="flex h-[200px] items-center justify-center text-muted-foreground"
>
Belum ada data
</div>
</CardContent>
</Card>
</div>
<!-- Row 8: Monthly Trends -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Monthly Revenue Trend -->
<Card>
<CardHeader>
<CardTitle class="text-base"
>Tren Pendapatan 6 Bulan</CardTitle
>
<CardDescription
>Total pendapatan per bulan</CardDescription
>
</CardHeader>
<CardContent>
<div
v-if="monthlyRevenueTrend.length > 0"
class="space-y-2"
>
<div
v-for="item in monthlyRevenueTrend"
:key="item.label"
class="flex items-center gap-3"
>
<span
class="w-16 text-xs text-muted-foreground"
>{{ item.label }}</span
>
<div
class="h-4 flex-1 overflow-hidden rounded-full bg-muted"
>
<div
class="h-full rounded-full bg-green-500 transition-all"
:style="{
width: `${Math.max(5, (item.total / Math.max(...monthlyRevenueTrend.map((r) => r.total))) * 100)}%`,
}"
/>
</div>
<span
class="w-24 text-right text-xs font-medium"
>{{ formatRupiah(item.total) }}</span
>
</div>
</div>
<div
v-else
class="flex h-[150px] items-center justify-center text-muted-foreground"
>
Belum ada data
</div>
</CardContent>
</Card>
<!-- Monthly Purchase Trend -->
<Card>
<CardHeader>
<CardTitle class="text-base"
>Tren Pembelian 6 Bulan</CardTitle
>
<CardDescription
>Total pembelian per bulan</CardDescription
>
</CardHeader>
<CardContent>
<div
v-if="monthlyPurchaseTrend.length > 0"
class="space-y-2"
>
<div
v-for="item in monthlyPurchaseTrend"
:key="item.label"
class="flex items-center gap-3"
>
<span
class="w-16 text-xs text-muted-foreground"
>{{ item.label }}</span
>
<div
class="h-4 flex-1 overflow-hidden rounded-full bg-muted"
>
<div
class="h-full rounded-full bg-blue-500 transition-all"
:style="{
width: `${Math.max(5, (item.total / Math.max(...monthlyPurchaseTrend.map((r) => r.total))) * 100)}%`,
}"
/>
</div>
<span
class="w-24 text-right text-xs font-medium"
>{{ formatRupiah(item.total) }}</span
>
</div>
</div>
<div
v-else
class="flex h-[150px] items-center justify-center text-muted-foreground"
>
Belum ada data Belum ada data
</div> </div>
</CardContent> </CardContent>