feat: enhance StatCard component with description prop and update chart tooltips for better localization
- Added optional description prop to StatCard for additional context. - Updated ChartTooltipContent to format numbers according to Indonesian locale. - Refactored revenue and expense charts in the Analysis page to use new chart configurations and improved tooltip content. - Introduced DashboardPieChart component for displaying order statistics by channel, payment type, marketing, and status. - Adjusted revenue summary calculations in the Dashboard page to reflect changes in data structure.
This commit is contained in:
parent
3fab97e7e5
commit
0cce4ee73d
@ -27,7 +27,7 @@ public function index(Request $request): Response
|
||||
|
||||
$attendance = $this->service->getAttendanceStats($startDate, $endDate);
|
||||
$myAttendance = $this->service->getMyAttendance($user, $startDate, $endDate);
|
||||
$cashOverview = $this->service->getCashOverview();
|
||||
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
|
||||
$rawMaterialStock = $this->service->getRawMaterialStock();
|
||||
$productStock = $this->service->getProductStock();
|
||||
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate);
|
||||
@ -42,6 +42,7 @@ public function index(Request $request): Response
|
||||
$topCustomers = $this->service->getTopCustomers($startDate, $endDate);
|
||||
$topProducts = $this->service->getTopProducts($startDate, $endDate);
|
||||
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
|
||||
$orderStats = $this->service->getOrderStats($startDate, $endDate);
|
||||
|
||||
return Inertia::render('admin/analysis/index', [
|
||||
'filters' => [
|
||||
@ -66,6 +67,7 @@ public function index(Request $request): Response
|
||||
'topCustomers' => $topCustomers,
|
||||
'topProducts' => $topProducts,
|
||||
'marketingSales' => $marketingSales,
|
||||
'orderStats' => $orderStats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Models\Attendance;
|
||||
@ -14,7 +17,9 @@
|
||||
use App\Models\Order;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\PurchaseItem;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\RestockItem;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -26,40 +31,42 @@ public function getAttendanceStats(?string $startDate, ?string $endDate): array
|
||||
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
|
||||
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
|
||||
|
||||
$workingDays = 0;
|
||||
$current = $start->copy();
|
||||
$employees = Employee::whereHas('user', fn ($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas('roles', fn ($r) => $r
|
||||
->whereHas('permissions', fn ($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
)
|
||||
->where('join_date', '<=', $end)
|
||||
->where(function ($q) use ($start) {
|
||||
$q->whereNull('resign_date')->orWhere('resign_date', '>=', $start);
|
||||
})
|
||||
->get();
|
||||
|
||||
while ($current->lte($end)) {
|
||||
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
|
||||
$workingDays++;
|
||||
}
|
||||
$current->addDay();
|
||||
}
|
||||
$employeeIds = $employees->pluck('id');
|
||||
$employeeCount = $employeeIds->count();
|
||||
|
||||
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
|
||||
$present = Attendance::whereIn('employee_id', $employeeIds)
|
||||
->whereBetween('attendance_date', [$start->toDateString(), $end->toDateString()])
|
||||
->distinct('employee_id')
|
||||
->count('employee_id');
|
||||
|
||||
$present = Attendance::whereBetween('attendance_date', [$start, $end])->count();
|
||||
|
||||
$leaveDays = LeaveRequest::approved()
|
||||
$onLeave = LeaveRequest::approved()
|
||||
->where('start_date', '<=', $end)
|
||||
->where('end_date', '>=', $start)
|
||||
->get()
|
||||
->reduce(function ($carry, $leave) use ($start, $end) {
|
||||
$leaveStart = max($leave->start_date->timestamp, $start->timestamp);
|
||||
$leaveEnd = min($leave->end_date->timestamp, $end->timestamp);
|
||||
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
|
||||
->whereIn('employee_id', $employeeIds)
|
||||
->count();
|
||||
|
||||
return $carry + max(0, $days);
|
||||
}, 0);
|
||||
|
||||
$absent = max(0, $workingDays - $present - $leaveDays);
|
||||
$absent = max(0, $employeeCount - $present - $onLeave);
|
||||
|
||||
return [
|
||||
'total_employees' => $totalEmployees,
|
||||
'total_employees' => $employeeCount,
|
||||
'present' => $present,
|
||||
'absent' => $absent,
|
||||
'on_leave' => $leaveDays,
|
||||
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
|
||||
'on_leave' => $onLeave,
|
||||
'percentage' => $employeeCount > 0 ? round(($present / $employeeCount) * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@ -112,7 +119,7 @@ public function getMyAttendance(User $user, ?string $startDate, ?string $endDate
|
||||
];
|
||||
}
|
||||
|
||||
public function getCashOverview(): array
|
||||
public function getCashOverview(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$cashAccount = CashAccount::first();
|
||||
|
||||
@ -126,32 +133,37 @@ public function getCashOverview(): array
|
||||
}
|
||||
|
||||
$transactions = $cashAccount->cashTransactions();
|
||||
$this->applyDateFilter($transactions, $startDate, $endDate, 'cash_transactions.created_at');
|
||||
|
||||
return [
|
||||
'total_balance' => $cashAccount->balance,
|
||||
'total_transactions' => (clone $transactions)->count(),
|
||||
'total_deposit' => (int) (clone $transactions)->where('type', 'deposit')->sum('amount'),
|
||||
'total_withdrawal' => (int) (clone $transactions)->where('type', 'withdrawal')->sum('amount'),
|
||||
'total_deposit' => (int) (clone $transactions)->where('type', CashTransactionType::DEPOSIT)->sum('amount'),
|
||||
'total_withdrawal' => (int) (clone $transactions)->where('type', CashTransactionType::WITHDRAWAL)->sum('amount'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getRawMaterialStock(): array
|
||||
{
|
||||
$prices = RawMaterialPrice::select('stock', 'price', 'raw_material_id')
|
||||
->with('rawMaterial:id,unit')
|
||||
$query = PurchaseItem::query()
|
||||
->join('purchases', 'purchase_items.purchase_id', '=', 'purchases.id')
|
||||
->leftJoin('raw_material_prices', 'purchase_items.raw_material_price_id', '=', 'raw_material_prices.id')
|
||||
->leftJoin('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id');
|
||||
|
||||
$items = $query->select('purchase_items.*', 'raw_materials.unit')
|
||||
->get();
|
||||
|
||||
$totalStock = $prices->sum('stock');
|
||||
$totalValue = $prices->sum(fn ($p) => $p->stock * $p->price);
|
||||
$totalQty = $items->sum('quantity');
|
||||
$totalValue = $items->sum('subtotal');
|
||||
|
||||
$byUnit = [
|
||||
'yard' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'),
|
||||
'meter' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'),
|
||||
'kilogram' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'),
|
||||
'yard' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::YARD)->sum('quantity'),
|
||||
'meter' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::METER)->sum('quantity'),
|
||||
'kilogram' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::KG)->sum('quantity'),
|
||||
];
|
||||
|
||||
return [
|
||||
'total_stock' => $totalStock,
|
||||
'total_stock' => $totalQty,
|
||||
'total_value' => $totalValue,
|
||||
'by_unit' => $byUnit,
|
||||
];
|
||||
@ -159,53 +171,45 @@ public function getRawMaterialStock(): array
|
||||
|
||||
public function getProductStock(): array
|
||||
{
|
||||
$variants = ProductVariant::select('id', 'product_id', 'stock', 'reject_stock', 'retail_stock')
|
||||
->with(['product:id,name', 'productPrices' => fn ($q) => $q->where('type', PriceType::CAPITAL)])
|
||||
$query = RestockItem::query()
|
||||
->join('restocks', 'restock_items.restock_id', '=', 'restocks.id');
|
||||
|
||||
$items = $query->select('restock_items.*', 'restocks.stock_type')
|
||||
->get();
|
||||
|
||||
$totalStock = $variants->sum('stock');
|
||||
$totalReject = $variants->sum('reject_stock');
|
||||
$totalRetail = $variants->sum('retail_stock');
|
||||
$totalQty = $items->sum('quantity');
|
||||
$totalValue = $items->sum('subtotal');
|
||||
|
||||
$totalValue = $variants->sum(function ($v) {
|
||||
$capitalPrice = $v->productPrices->first()?->price ?? 0;
|
||||
|
||||
return $v->stock * $capitalPrice;
|
||||
});
|
||||
$byType = $items->groupBy(fn ($i) => $i->stock_type ?? 'unknown')
|
||||
->map(fn ($group) => $group->sum('quantity'))
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total_stock' => $totalStock,
|
||||
'total_reject' => $totalReject,
|
||||
'total_retail' => $totalRetail,
|
||||
'total_stock' => $totalQty,
|
||||
'total_value' => $totalValue,
|
||||
'total_products' => ProductVariant::distinct('product_id')->count('product_id'),
|
||||
'total_variants' => ProductVariant::count(),
|
||||
'total_categories' => DB::table('product_categories')->distinct('category_id')->count('category_id'),
|
||||
'by_type' => $byType,
|
||||
];
|
||||
}
|
||||
|
||||
public function getRevenueSummary(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$stats = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as total_deduction')
|
||||
->selectRaw('COALESCE(SUM(total_amount) - SUM(discount), 0) as net')
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN price_type != 'retail' THEN total_amount ELSE 0 END), 0) as net_warehouse")
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN price_type = 'retail' THEN total_amount ELSE 0 END), 0) as net_retail")
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_revenue' => (int) $stats->total_revenue,
|
||||
'total_discount' => (int) $stats->total_discount,
|
||||
'total_deduction' => (int) $stats->total_deduction,
|
||||
'net' => (int) $stats->net,
|
||||
'net_warehouse' => (int) $stats->net_warehouse,
|
||||
'net_retail' => (int) $stats->net_retail,
|
||||
'cogs' => (int) $stats->total_cogs,
|
||||
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs,
|
||||
'total_orders' => (int) $stats->total_orders,
|
||||
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
|
||||
];
|
||||
@ -214,19 +218,26 @@ public function getRevenueSummary(?string $startDate, ?string $endDate): array
|
||||
public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$monthly = (clone $query)
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
|
||||
->selectRaw('COALESCE(SUM(total_amount) - SUM(discount), 0) as net')
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN price_type != 'retail' THEN total_amount ELSE 0 END), 0) as net_warehouse")
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN price_type = 'retail' THEN total_amount ELSE 0 END), 0) as net_retail")
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as deduction')
|
||||
->selectRaw('COALESCE(SUM(total_amount) - SUM(cogs), 0) as net')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as discount')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
|
||||
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
|
||||
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
|
||||
->get()
|
||||
->map(fn ($item) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction']));
|
||||
->map(fn ($item) => [
|
||||
'month' => $item->month,
|
||||
'total' => (int) $item->total,
|
||||
'net' => (int) $item->net,
|
||||
'discount' => (int) $item->discount,
|
||||
'deduction' => (int) $item->deduction,
|
||||
'cogs' => (int) $item->cogs,
|
||||
]);
|
||||
|
||||
return $monthly->values()->toArray();
|
||||
}
|
||||
@ -234,17 +245,22 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
|
||||
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$monthly = (clone $query)
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
->selectRaw("SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END) as store")
|
||||
->selectRaw("SUM(CASE WHEN channel = 'shopee' THEN total_amount ELSE 0 END) as shopee")
|
||||
->selectRaw("SUM(CASE WHEN channel = 'tiktok' THEN total_amount ELSE 0 END) as tiktok")
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END), 0) as store")
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'shopee' THEN total_amount ELSE 0 END), 0) as shopee")
|
||||
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'tiktok' THEN total_amount ELSE 0 END), 0) as tiktok")
|
||||
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
|
||||
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
|
||||
->get()
|
||||
->map(fn ($item) => $item->only(['month', 'store', 'shopee', 'tiktok']));
|
||||
->map(fn ($item) => [
|
||||
'month' => $item->month,
|
||||
'store' => (int) $item->store,
|
||||
'shopee' => (int) $item->shopee,
|
||||
'tiktok' => (int) $item->tiktok,
|
||||
]);
|
||||
|
||||
return $monthly->values()->toArray();
|
||||
}
|
||||
@ -252,7 +268,7 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate)
|
||||
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$data = (clone $query)
|
||||
->select('payment_type')
|
||||
@ -271,30 +287,30 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate): a
|
||||
public function getExpenseSummary(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$expenseQuery = Expense::query();
|
||||
$this->applyDateFilter($expenseQuery, $startDate, $endDate);
|
||||
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
|
||||
|
||||
$advanceQuery = EmployeeAdvance::where('status', 'paid');
|
||||
$this->applyDateFilter($advanceQuery, $startDate, $endDate);
|
||||
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
|
||||
|
||||
$purchaseQuery = Purchase::query();
|
||||
$this->applyDateFilter($purchaseQuery, $startDate, $endDate);
|
||||
$this->applyDateFilter($purchaseQuery, $startDate, $endDate, 'purchases.created_at');
|
||||
|
||||
$expenseTotal = (clone $expenseQuery)->sum('amount');
|
||||
$advanceTotal = (clone $advanceQuery)->sum('amount');
|
||||
$purchaseTotal = (clone $purchaseQuery)->sum('total');
|
||||
|
||||
return [
|
||||
'total' => $expenseTotal + $advanceTotal + $purchaseTotal,
|
||||
'purchase_total' => $purchaseTotal,
|
||||
'expense_total' => $expenseTotal,
|
||||
'advance_total' => $advanceTotal,
|
||||
'total' => (int) ($expenseTotal + $advanceTotal + $purchaseTotal),
|
||||
'purchase_total' => (int) $purchaseTotal,
|
||||
'expense_total' => (int) $expenseTotal,
|
||||
'advance_total' => (int) $advanceTotal,
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonthlyExpense(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$expenseMonthly = Expense::query();
|
||||
$this->applyDateFilter($expenseMonthly, $startDate, $endDate);
|
||||
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
|
||||
|
||||
$expenseByMonth = (clone $expenseMonthly)->toBase()
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
@ -304,7 +320,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
|
||||
->keyBy('month');
|
||||
|
||||
$purchaseMonthly = Purchase::query();
|
||||
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate);
|
||||
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate, 'purchases.created_at');
|
||||
|
||||
$purchaseByMonth = (clone $purchaseMonthly)->toBase()
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
@ -314,7 +330,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
|
||||
->keyBy('month');
|
||||
|
||||
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
|
||||
$this->applyDateFilter($advanceMonthly, $startDate, $endDate);
|
||||
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
|
||||
|
||||
$advanceByMonth = (clone $advanceMonthly)->toBase()
|
||||
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
|
||||
@ -345,7 +361,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
|
||||
public function getBusyHours(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$hours = range(0, 23);
|
||||
$hourCounts = (clone $query)
|
||||
@ -366,7 +382,7 @@ public function getBusyHours(?string $startDate, ?string $endDate): array
|
||||
public function getProfitMetrics(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$stats = (clone $query)
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
@ -399,7 +415,7 @@ public function getProfitMetrics(?string $startDate, ?string $endDate): array
|
||||
public function getTopSuppliers(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Purchase::query();
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'purchases.created_at');
|
||||
|
||||
return (clone $query)->toBase()
|
||||
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
|
||||
@ -416,7 +432,7 @@ public function getTopSuppliers(?string $startDate, ?string $endDate): array
|
||||
public function getTopCustomers(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
return (clone $query)->toBase()
|
||||
->join('customers', 'orders.customer_id', '=', 'customers.id')
|
||||
@ -433,7 +449,7 @@ public function getTopCustomers(?string $startDate, ?string $endDate): array
|
||||
public function getTopProducts(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
return (clone $query)->toBase()
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
@ -453,23 +469,101 @@ public function getMarketingSales(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED)
|
||||
->whereNotNull('orders.marketing_id');
|
||||
$this->applyDateFilter($query, $startDate, $endDate);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
return (clone $query)->toBase()
|
||||
$orders = (clone $query)
|
||||
->join('users', 'orders.marketing_id', '=', 'users.id')
|
||||
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
|
||||
->leftJoin('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->select('user_profiles.full_name as marketing_name')
|
||||
->select('orders.marketing_id', 'user_profiles.full_name as marketing_name')
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(order_items.quantity), 0) as total_products_sold')
|
||||
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
|
||||
->selectRaw('COALESCE(SUM(orders.subtotal), 0) as total_subtotal')
|
||||
->selectRaw('COALESCE(SUM(orders.discount), 0) as total_discount')
|
||||
->selectRaw('ROUND(COALESCE(SUM(orders.total_amount), 0) / COUNT(DISTINCT orders.id)) as avg_order')
|
||||
->groupBy('user_profiles.full_name')
|
||||
->orderByDesc('total_revenue')
|
||||
->groupBy('orders.marketing_id', 'user_profiles.full_name')
|
||||
->get();
|
||||
|
||||
$productCounts = (clone $query)
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->selectRaw('orders.marketing_id, SUM(order_items.quantity) as total_qty')
|
||||
->groupBy('orders.marketing_id')
|
||||
->pluck('total_qty', 'marketing_id');
|
||||
|
||||
return $orders->map(function ($item) use ($productCounts) {
|
||||
$totalOrders = (int) $item->total_orders;
|
||||
$totalRevenue = (int) $item->total_revenue;
|
||||
|
||||
return [
|
||||
'marketing_name' => $item->marketing_name,
|
||||
'total_orders' => $totalOrders,
|
||||
'total_products_sold' => (int) ($productCounts[$item->marketing_id] ?? 0),
|
||||
'total_revenue' => $totalRevenue,
|
||||
'total_subtotal' => (int) $item->total_subtotal,
|
||||
'total_discount' => (int) $item->total_discount,
|
||||
'avg_order' => $totalOrders > 0 ? (int) ($totalRevenue / $totalOrders) : 0,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
public function getOrderStats(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$baseQuery = Order::query();
|
||||
$this->applyDateFilter($baseQuery, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
|
||||
$count = (clone $baseQuery)->where('channel', $channel)->count();
|
||||
$label = OrderChannel::from($channel)->label();
|
||||
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'total' => (int) (clone $baseQuery)->where('channel', $channel)->sum('total_amount'),
|
||||
];
|
||||
});
|
||||
|
||||
$byPaymentType = collect(PaymentType::values())->map(function ($paymentType) use ($baseQuery) {
|
||||
$count = (clone $baseQuery)->where('payment_type', $paymentType)->count();
|
||||
$label = PaymentType::from($paymentType)->label();
|
||||
|
||||
return [
|
||||
'payment_type' => $paymentType,
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'total' => (int) (clone $baseQuery)->where('payment_type', $paymentType)->sum('total_amount'),
|
||||
];
|
||||
});
|
||||
|
||||
$byMarketing = (clone $baseQuery)
|
||||
->whereNotNull('marketing_id')
|
||||
->select('marketing_id')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
|
||||
->groupBy('marketing_id')
|
||||
->with('marketing:id')
|
||||
->get()
|
||||
->toArray();
|
||||
->map(fn($item) => [
|
||||
'name' => $item->marketing?->userProfile->full_name ?? '-',
|
||||
'count' => $item->count,
|
||||
'total' => (int) $item->total,
|
||||
]);
|
||||
|
||||
$byStatus = collect(OrderStatus::values())->map(function ($status) use ($baseQuery) {
|
||||
$count = (clone $baseQuery)->where('status', $status)->count();
|
||||
$label = OrderStatus::from($status)->label();
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'by_channel' => $byChannel,
|
||||
'by_payment_type' => $byPaymentType,
|
||||
'by_marketing' => $byMarketing,
|
||||
'by_status' => $byStatus,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyDateFilter($query, ?string $startDate, ?string $endDate, string $dateColumn = 'created_at'): void
|
||||
|
||||
@ -90,26 +90,16 @@ public function getRevenueSummary(): array
|
||||
->selectRaw('COUNT(*) as total_orders')
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
|
||||
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
|
||||
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
|
||||
->first();
|
||||
|
||||
$marketplaceFees = (clone $baseQuery)
|
||||
->whereNotNull('marketplace_settings_snapshot')
|
||||
->selectRaw("COALESCE(SUM(JSON_EXTRACT(marketplace_settings_snapshot, '$.total_fee_amount')), 0) as total")
|
||||
->first()
|
||||
->total;
|
||||
|
||||
$negoDiff = (clone $baseQuery)
|
||||
->whereNotNull('nego_price')
|
||||
->selectRaw('COALESCE(SUM(nego_price), 0) as total')
|
||||
->first()
|
||||
->total;
|
||||
|
||||
return [
|
||||
'total_revenue' => (int) $stats->total_revenue,
|
||||
'total_discount' => (int) $stats->total_discount,
|
||||
'total_cogs' => (int) $stats->total_cogs,
|
||||
'total_deduction' => (int) $marketplaceFees + (int) $negoDiff,
|
||||
'total_deduction' => (int) $stats->total_deduction,
|
||||
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs,
|
||||
'total_orders' => (int) $stats->total_orders,
|
||||
];
|
||||
}
|
||||
|
||||
221
chart.tsx
Normal file
221
chart.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
|
||||
export const description = "An interactive bar chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: "Page Views",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartBarInteractive() {
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("desktop")
|
||||
|
||||
const total = React.useMemo(
|
||||
() => ({
|
||||
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
|
||||
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Bar Chart - Interactive</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{["desktop", "mobile"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig
|
||||
return (
|
||||
<button
|
||||
key={chart}
|
||||
data-active={activeChart === chart}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chartConfig[chart].label}
|
||||
</span>
|
||||
<span className="text-lg leading-none font-bold sm:text-3xl">
|
||||
{total[key as keyof typeof total].toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -13,11 +13,12 @@ type StatCardProps = {
|
||||
mainLabel?: string;
|
||||
mainValue: string | number;
|
||||
subLabel?: string;
|
||||
description?: string;
|
||||
items?: StatItem[];
|
||||
cols?: 2 | 3 | 4;
|
||||
};
|
||||
|
||||
export function StatCard({ title, icon: Icon, mainLabel, mainValue, subLabel, items = [], cols = 3 }: StatCardProps) {
|
||||
export function StatCard({ title, icon: Icon, mainLabel, mainValue, subLabel, description, items = [], cols = 3 }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@ -28,6 +29,7 @@ export function StatCard({ title, icon: Icon, mainLabel, mainValue, subLabel, it
|
||||
{mainLabel && <p className="text-xs text-muted-foreground">{mainLabel}</p>}
|
||||
<p className="text-2xl font-bold">{mainValue}</p>
|
||||
{subLabel && <p className="text-xs text-muted-foreground">{subLabel}</p>}
|
||||
{description && <p className="mt-1 text-[10px] text-muted-foreground italic">{description}</p>}
|
||||
{items.length > 0 && (
|
||||
<div className={cn('mt-3 grid gap-2', cols === 2 && 'grid-cols-2', cols === 3 && 'grid-cols-3', cols === 4 && 'grid-cols-4')}>
|
||||
{items.map((item, index) => (
|
||||
|
||||
@ -253,8 +253,10 @@ function ChartTooltipContent({
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
? item.value.toLocaleString('id-ID')
|
||||
: typeof item.value === "string" && !isNaN(Number(item.value))
|
||||
? Number(item.value).toLocaleString('id-ID')
|
||||
: String(item.value ?? '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { StatCard } from '@/components/card/stat-card';
|
||||
import { DatePicker } from '@/components/inputs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -11,13 +12,12 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import {
|
||||
Banknote,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@ -31,7 +31,6 @@ import {
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type Filters = {
|
||||
@ -73,20 +72,19 @@ type AnalysisProps = {
|
||||
};
|
||||
productStock: {
|
||||
total_stock: number;
|
||||
total_reject: number;
|
||||
total_retail: number;
|
||||
total_value: number;
|
||||
total_products: number;
|
||||
total_variants: number;
|
||||
total_categories: number;
|
||||
by_type: {
|
||||
good?: number;
|
||||
reject?: number;
|
||||
retail?: number;
|
||||
};
|
||||
};
|
||||
revenueSummary: {
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
total_deduction: number;
|
||||
cogs: number;
|
||||
net: number;
|
||||
net_warehouse: number;
|
||||
net_retail: number;
|
||||
total_orders: number;
|
||||
avg_order: number;
|
||||
};
|
||||
@ -94,9 +92,9 @@ type AnalysisProps = {
|
||||
month: string;
|
||||
total: number;
|
||||
net: number;
|
||||
net_warehouse: number;
|
||||
net_retail: number;
|
||||
discount: number;
|
||||
deduction: number;
|
||||
cogs: number;
|
||||
}>;
|
||||
monthlyRevenueByChannel: Array<{
|
||||
month: string;
|
||||
@ -160,22 +158,77 @@ type AnalysisProps = {
|
||||
total_discount: number;
|
||||
avg_order: number;
|
||||
}>;
|
||||
orderStats: {
|
||||
by_channel: Array<{
|
||||
channel: string;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>;
|
||||
by_payment_type: Array<{
|
||||
payment_type: string;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>;
|
||||
by_marketing: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>;
|
||||
by_status: Array<{
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const REVENUE_COLORS: Record<string, string> = {
|
||||
total: '#60a5fa',
|
||||
net: '#22c55e',
|
||||
net_warehouse: '#10b981',
|
||||
net_retail: '#06b6d4',
|
||||
deduction: '#f97316',
|
||||
};
|
||||
const revenueChartConfig = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
net: {
|
||||
label: 'Bersih',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
deduction: {
|
||||
label: 'Potongan',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
discount: {
|
||||
label: 'Diskon',
|
||||
color: 'var(--chart-4)',
|
||||
},
|
||||
cogs: {
|
||||
label: 'HPP',
|
||||
color: 'var(--chart-5)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const EXPENSE_COLORS: Record<string, string> = {
|
||||
total: '#60a5fa',
|
||||
purchase: '#f97316',
|
||||
expense: '#a855f7',
|
||||
advance: '#ef4444',
|
||||
};
|
||||
const revenueKeys = ['total', 'net', 'deduction', 'discount', 'cogs'] as const;
|
||||
|
||||
const expenseChartConfig = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
purchase: {
|
||||
label: 'Belanja',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
expense: {
|
||||
label: 'Pengeluaran',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
advance: {
|
||||
label: 'Kasbon',
|
||||
color: 'var(--chart-4)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const;
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
store: '#22c55e',
|
||||
@ -190,6 +243,88 @@ const PAYMENT_COLORS: Record<string, string> = {
|
||||
marketplace: '#f97316',
|
||||
};
|
||||
|
||||
const PIE_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
];
|
||||
|
||||
type PieChartItem = {
|
||||
name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type DashboardPieChartProps = {
|
||||
title: string;
|
||||
data: PieChartItem[];
|
||||
dataKey: string;
|
||||
nameKey: string;
|
||||
};
|
||||
|
||||
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
|
||||
const hasData = data.length > 0 && data.some((d) => d.count > 0);
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const config: ChartConfig = {};
|
||||
data.forEach((item, index) => {
|
||||
config[item.name] = {
|
||||
label: item.name,
|
||||
color: PIE_COLORS[index % PIE_COLORS.length],
|
||||
};
|
||||
});
|
||||
return config;
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
fill: PIE_COLORS[data.indexOf(item) % PIE_COLORS.length],
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="items-center pb-0">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-0">
|
||||
{hasData ? (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey={nameKey}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey={dataKey}
|
||||
nameKey={nameKey}
|
||||
/>
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent nameKey={nameKey} />}
|
||||
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
@ -264,11 +399,14 @@ export default function Analysis({
|
||||
topCustomers,
|
||||
topProducts,
|
||||
marketingSales,
|
||||
orderStats,
|
||||
}: AnalysisProps) {
|
||||
const { can, hasAnyRole, hasRole } = useCan();
|
||||
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
||||
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
||||
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
||||
|
||||
const hasActiveFilters = !!startDate || !!endDate;
|
||||
|
||||
@ -348,26 +486,24 @@ export default function Analysis({
|
||||
}, [busyHours]);
|
||||
|
||||
const visibleExpenseCharts = useMemo(() => {
|
||||
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
|
||||
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return charts;
|
||||
return expenseKeys;
|
||||
}
|
||||
|
||||
return charts.filter((c) => c !== 'purchase');
|
||||
return expenseKeys.filter((c) => c !== 'purchase');
|
||||
}, [hasAnyRole]);
|
||||
|
||||
const sectionOrder = useMemo(() => {
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return { statCards: 1, revenue: 5, revenueByChannel: 6, expense: 7, profitGross: 8, totalOrder: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topSuppliers: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
|
||||
}
|
||||
|
||||
if (hasAnyRole(['admin_toko', 'direktur'])) {
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, expense: 6, profitGross: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 };
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 };
|
||||
}
|
||||
|
||||
if (hasRole('marketing')) {
|
||||
return { statCards: 1, revenue: 2, revenueByChannel: 3, totalOrder: 4, topProducts: 5, topCustomers: 6 };
|
||||
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
|
||||
}
|
||||
|
||||
return {};
|
||||
@ -412,22 +548,7 @@ export default function Analysis({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.statCards ?? 99 }}>
|
||||
{can('analysis.attendance') && isManager && (
|
||||
<StatCard
|
||||
title="Kehadiran"
|
||||
icon={UserCheck}
|
||||
mainLabel="Total Karyawan"
|
||||
mainValue={attendance.total_employees}
|
||||
subLabel={`${attendance.percentage}% hadir`}
|
||||
items={[
|
||||
{ label: 'Hadir', value: attendance.present },
|
||||
{ label: 'Tidak Hadir', value: attendance.absent },
|
||||
{ label: 'Cuti', value: attendance.on_leave },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" style={{ order: sectionOrder.statCards ?? 99 }}>
|
||||
{can('analysis.attendance') && !isManager && myAttendance && (
|
||||
<StatCard
|
||||
title="Kehadiran Saya"
|
||||
@ -462,9 +583,10 @@ export default function Analysis({
|
||||
<StatCard
|
||||
title="Bahan Baku"
|
||||
icon={Package}
|
||||
mainLabel="Total Stok"
|
||||
mainLabel="Total Belanja"
|
||||
mainValue={rawMaterialStock.total_stock.toLocaleString('id-ID')}
|
||||
subLabel={`Rp${formatRupiah(rawMaterialStock.total_value)}`}
|
||||
description="Tidak terpengaruh filter tanggal"
|
||||
items={[
|
||||
{ label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' },
|
||||
{ label: 'Meter', value: rawMaterialStock.by_unit?.meter?.toLocaleString('id-ID') ?? '0' },
|
||||
@ -477,55 +599,80 @@ export default function Analysis({
|
||||
<StatCard
|
||||
title="Stok Produk"
|
||||
icon={ShoppingCart}
|
||||
mainLabel="Total Stok"
|
||||
mainValue={(productStock.total_stock + productStock.total_reject + productStock.total_retail).toLocaleString('id-ID')}
|
||||
mainLabel="Total Restock"
|
||||
mainValue={productStock.total_stock.toLocaleString('id-ID')}
|
||||
subLabel={`Rp${formatRupiah(productStock.total_value)}`}
|
||||
description="Tidak terpengaruh filter tanggal"
|
||||
items={[
|
||||
{ label: 'Stok Bagus', value: productStock.total_stock.toLocaleString('id-ID') },
|
||||
{ label: 'Stok Reject', value: productStock.total_reject.toLocaleString('id-ID') },
|
||||
{ label: 'Stok Ecer', value: productStock.total_retail.toLocaleString('id-ID') },
|
||||
{ label: 'Bagus', value: (productStock.by_type?.good ?? 0).toLocaleString('id-ID') },
|
||||
{ label: 'Reject', value: (productStock.by_type?.reject ?? 0).toLocaleString('id-ID') },
|
||||
{ label: 'Ecer', value: (productStock.by_type?.retail ?? 0).toLocaleString('id-ID') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{can('analysis.revenue') && (
|
||||
<Card className="py-4 sm:py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
|
||||
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Pendapatan</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{Object.entries(REVENUE_COLORS).filter(([key]) => {
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasRole('cashier')) {
|
||||
return key === 'total' || key === 'deduction';
|
||||
}
|
||||
|
||||
return key === 'total' || key === 'net' || key === 'deduction';
|
||||
}).map(([key]) => (
|
||||
<div key={key} className="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">
|
||||
<span className="text-xs text-muted-foreground">{key === 'net_warehouse' ? 'Total Gudang' : key === 'net_retail' ? 'Total Ecer' : key === 'total' ? 'Total' : key === 'net' ? 'Bersih' : 'Potongan'}</span>
|
||||
<span className="text-sm">Rp{formatRupiah(key === 'total' ? revenueSummary.total_revenue : key === 'net' ? revenueSummary.net : key === 'net_warehouse' ? revenueSummary.net_warehouse : key === 'net_retail' ? revenueSummary.net_retail : revenueSummary.total_deduction)}</span>
|
||||
</div>
|
||||
))}
|
||||
{revenueKeys.filter((key) => {
|
||||
if (hasAnyRole(['owner', 'developer'])) return true;
|
||||
if (hasRole('cashier')) return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs';
|
||||
return key === 'total' || key === 'discount' || key === 'deduction' || key === 'net' || key === 'cogs';
|
||||
}).map((key) => {
|
||||
const value = key === 'total' ? revenueSummary.total_revenue : key === 'discount' ? revenueSummary.total_discount : key === 'net' ? revenueSummary.net : key === 'cogs' ? revenueSummary.cogs : revenueSummary.total_deduction;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-active={activeRevenueKey === key}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
||||
onClick={() => setActiveRevenueKey(key)}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{revenueChartConfig[key].label}
|
||||
</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||
Rp{formatRupiah(value)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{monthlyRevenue.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={monthlyRevenue}>
|
||||
<ChartContainer config={revenueChartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={monthlyRevenue} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `Rp${formatRupiahShort(v)}`} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="total" fill={REVENUE_COLORS.total} radius={[4, 4, 0, 0]} name="Total" />
|
||||
<Bar dataKey="deduction" fill={REVENUE_COLORS.deduction} radius={[4, 4, 0, 0]} name="Potongan" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">{revenueChartConfig[activeRevenueKey]?.label ?? name}</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
Rp{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={activeRevenueKey} fill={`var(--color-${activeRevenueKey})`} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
||||
)}
|
||||
@ -618,35 +765,107 @@ export default function Analysis({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can('analysis.revenue') && (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4" style={{ order: sectionOrder.orderPie ?? 99 }}>
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Channel"
|
||||
data={orderStats.by_channel.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
/>
|
||||
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Pembayaran"
|
||||
data={orderStats.by_payment_type.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
/>
|
||||
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Marketing"
|
||||
data={orderStats.by_marketing.map((item) => ({
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
/>
|
||||
|
||||
<DashboardPieChart
|
||||
title="Pesanan per Status"
|
||||
data={orderStats.by_status.map((item) => ({
|
||||
name: item.label,
|
||||
count: item.count,
|
||||
}))}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can('analysis.expense') && (
|
||||
<Card className="py-4 sm:py-0" style={{ order: sectionOrder.expense ?? 99 }}>
|
||||
<Card className="py-0" style={{ order: sectionOrder.expense ?? 99 }}>
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Pengeluaran</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{visibleExpenseCharts.map((chart) => (
|
||||
<div key={chart} className="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">
|
||||
<span className="text-xs text-muted-foreground">{chart === 'total' ? 'Total' : chart === 'purchase' ? 'Belanja' : chart === 'expense' ? 'Pengeluaran' : 'Kasbon'}</span>
|
||||
<span className="text-sm">Rp{formatRupiah(chart === 'total' ? expenseSummary.total : chart === 'purchase' ? expenseSummary.purchase_total : chart === 'expense' ? expenseSummary.expense_total : expenseSummary.advance_total)}</span>
|
||||
</div>
|
||||
))}
|
||||
{visibleExpenseCharts.map((key) => {
|
||||
const value = key === 'total' ? expenseSummary.total : key === 'purchase' ? expenseSummary.purchase_total : key === 'expense' ? expenseSummary.expense_total : expenseSummary.advance_total;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-active={activeExpenseKey === key}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
||||
onClick={() => setActiveExpenseKey(key)}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{expenseChartConfig[key].label}
|
||||
</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||
Rp{formatRupiah(value)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{monthlyExpense.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={monthlyExpense}>
|
||||
<ChartContainer config={expenseChartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={monthlyExpense} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `Rp${formatRupiahShort(v)}`} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="total" fill={EXPENSE_COLORS.total} radius={[4, 4, 0, 0]} name="Total" />
|
||||
<Bar dataKey="purchase" fill={EXPENSE_COLORS.purchase} radius={[4, 4, 0, 0]} name="Belanja" />
|
||||
<Bar dataKey="expense" fill={EXPENSE_COLORS.expense} radius={[4, 4, 0, 0]} name="Pengeluaran" />
|
||||
<Bar dataKey="advance" fill={EXPENSE_COLORS.advance} radius={[4, 4, 0, 0]} name="Kasbon" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">{expenseChartConfig[activeExpenseKey]?.label ?? name}</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
Rp{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={activeExpenseKey} fill={`var(--color-${activeExpenseKey})`} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pengeluaran</div>
|
||||
)}
|
||||
@ -654,24 +873,6 @@ export default function Analysis({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(can('analysis.profit_gross') || can('analysis.profit_hpp')) && (
|
||||
<div style={{ order: sectionOrder.profitGross ?? 99 }}>
|
||||
<StatCard
|
||||
title="Laba Kotor"
|
||||
icon={TrendingUp}
|
||||
mainLabel="Laba Kotor"
|
||||
mainValue={`Rp${formatRupiah(profitMetrics.gross_profit)}`}
|
||||
items={[
|
||||
{ label: 'Pendapatan', value: `Rp${formatRupiah(revenueSummary.total_revenue)}` },
|
||||
{ label: 'HPP', value: `Rp${formatRupiah(profitMetrics.hpp)}` },
|
||||
...(!hasRole('cashier')
|
||||
? [{ label: 'Laba Bersih', value: `Rp${formatRupiah(profitMetrics.net_profit)} (${profitMetrics.profit_margin}%)` }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can('analysis.profit_orders') && (
|
||||
<div style={{ order: sectionOrder.totalOrder ?? 99 }}>
|
||||
<StatCard
|
||||
@ -692,7 +893,6 @@ export default function Analysis({
|
||||
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Penjualan Marketing</CardTitle>
|
||||
<CardDescription>Rekap penjualan per marketing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{marketingSales.length > 0 ? (
|
||||
@ -735,19 +935,37 @@ export default function Analysis({
|
||||
<Card style={{ order: sectionOrder.topSuppliers ?? 99 }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top 5 Supplier</CardTitle>
|
||||
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{topSuppliers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={topSuppliers.map((s) => ({ name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name, amount: s.total_amount }))}>
|
||||
<ChartContainer config={{ amount: { label: 'Total Pembelian', color: 'var(--chart-1)' } }} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={topSuppliers.map((s) => ({ name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name, amount: s.total_amount }))} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `Rp${formatRupiahShort(v)}`} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="amount" fill="#60a5fa" radius={[4, 4, 0, 0]} name="Total Pembelian" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">Total Pembelian</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
Rp{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="var(--color-amount)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data supplier</div>
|
||||
)}
|
||||
@ -759,19 +977,37 @@ export default function Analysis({
|
||||
<Card style={{ order: sectionOrder.topProducts ?? 99 }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top 5 Produk</CardTitle>
|
||||
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{topProducts.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={topProducts.map((p) => ({ name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name, qty: p.total_qty }))}>
|
||||
<ChartContainer config={{ qty: { label: 'Jumlah Terjual', color: 'var(--chart-3)' } }} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={topProducts.map((p) => ({ name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name, qty: p.total_qty }))} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="qty" fill="#a855f7" radius={[4, 4, 0, 0]} name="Jumlah Terjual" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">Jumlah Terjual</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="qty" fill="var(--color-qty)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data produk</div>
|
||||
)}
|
||||
@ -783,19 +1019,37 @@ export default function Analysis({
|
||||
<Card style={{ order: sectionOrder.topCustomers ?? 99 }}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top 5 Pelanggan</CardTitle>
|
||||
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{topCustomers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={topCustomers.map((c) => ({ name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, amount: c.total_amount }))}>
|
||||
<ChartContainer config={{ amount: { label: 'Total Pesanan', color: 'var(--chart-2)' } }} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={topCustomers.map((c) => ({ name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, amount: c.total_amount }))} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `Rp${formatRupiahShort(v)}`} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="amount" fill="#22c55e" radius={[4, 4, 0, 0]} name="Total Pesanan" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">Total Pesanan</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
Rp{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="var(--color-amount)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pelanggan</div>
|
||||
)}
|
||||
@ -804,30 +1058,52 @@ export default function Analysis({
|
||||
)}
|
||||
|
||||
{can('analysis.busy_hours') && (
|
||||
<Card style={{ order: sectionOrder.busyHours ?? 99 }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Jam Sibuk Toko</CardTitle>
|
||||
<Card className="py-0" style={{ order: sectionOrder.busyHours ?? 99 }}>
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Jam Sibuk Toko</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l sm:border-t-0 sm:border-l sm:px-5 sm:py-3">
|
||||
<span className="text-[10px] text-muted-foreground">Jam Tersibuk</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm text-primary">{peakHour.hour}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Jam Tersibuk</p>
|
||||
<p className="text-2xl font-bold text-primary">{peakHour.hour}</p>
|
||||
<p className="text-xs text-muted-foreground">{peakHour.orders} pesanan</p>
|
||||
<div className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3">
|
||||
<span className="text-[10px] text-muted-foreground">Pesanan</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">{peakHour.orders.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{busyHours.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={busyHours}>
|
||||
<ChartContainer config={{ orders: { label: 'Pesanan', color: 'var(--chart-1)' } }} className="aspect-auto h-[250px] w-full">
|
||||
<BarChart data={busyHours} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="hour" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => Math.round(v).toString()} />
|
||||
<Tooltip content={<BarTooltip />} />
|
||||
<Bar dataKey="orders" fill="#60a5fa" radius={[4, 4, 0, 0]} name="Pesanan" />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">Pesanan</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="orders" fill="var(--color-orders)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pesanan</div>
|
||||
)}
|
||||
|
||||
@ -343,7 +343,7 @@ export default function Dashboard({
|
||||
items={[
|
||||
{
|
||||
label: 'Bersih',
|
||||
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_cogs - revenueSummary.total_deduction)}`,
|
||||
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_cogs)}`,
|
||||
},
|
||||
{
|
||||
label: 'Potongan',
|
||||
@ -353,7 +353,12 @@ export default function Dashboard({
|
||||
label: 'Diskon',
|
||||
value: `Rp${formatRupiah(revenueSummary.total_discount)}`,
|
||||
},
|
||||
{
|
||||
label: 'HPP',
|
||||
value: `Rp${formatRupiah(revenueSummary.total_cogs)}`,
|
||||
},
|
||||
]}
|
||||
cols={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user