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:
Yoga Pangestu 2026-08-09 16:30:16 +07:00
parent 3fab97e7e5
commit 0cce4ee73d
8 changed files with 861 additions and 269 deletions

View File

@ -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,
]);
}
}

View File

@ -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

View File

@ -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
View 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>
)
}

View File

@ -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) => (

View File

@ -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>

View File

@ -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>
)}

View File

@ -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}
/>
)}