feat: enhance dashboard with detailed statistics and charts

- Refactored the dashboard component to include attendance, revenue, and expense summaries.
- Added donut charts for order statistics by channel, payment type, marketing, and status.
- Implemented a greeting message based on the current time and user information.
- Updated routing to use DashboardController for the dashboard view.
- Introduced a new AnalysisController for future analysis features.
This commit is contained in:
Yoga Pangestu 2026-08-07 08:35:01 +07:00
parent 7a1b107ce5
commit c6a87a64c9
14 changed files with 5451 additions and 99 deletions

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Services\AnalysisService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AnalysisController extends Controller
{
public function __construct(
private AnalysisService $service
) {}
public function index(Request $request): Response
{
$user = $request->user();
$isManager = $user->hasAnyRole(['owner', 'developer', 'admin-toko', 'direktur']);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$attendance = $this->service->getAttendanceStats($startDate, $endDate);
$myAttendance = $this->service->getMyAttendance($user, $startDate, $endDate);
$cashOverview = $this->service->getCashOverview();
$rawMaterialStock = $this->service->getRawMaterialStock();
$productStock = $this->service->getProductStock();
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate);
$revenueByPaymentType = $this->service->getRevenueByPaymentType($startDate, $endDate);
$expenseSummary = $this->service->getExpenseSummary($startDate, $endDate);
$monthlyExpense = $this->service->getMonthlyExpense($startDate, $endDate);
$busyHours = $this->service->getBusyHours($startDate, $endDate);
$profitMetrics = $this->service->getProfitMetrics($startDate, $endDate);
$topSuppliers = $this->service->getTopSuppliers($startDate, $endDate);
$topCustomers = $this->service->getTopCustomers($startDate, $endDate);
$topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
return Inertia::render('admin/analysis/index', [
'filters' => [
'start_date' => $startDate,
'end_date' => $endDate,
],
'attendance' => $attendance,
'myAttendance' => $myAttendance,
'isManager' => $isManager,
'cashOverview' => $cashOverview,
'rawMaterialStock' => $rawMaterialStock,
'productStock' => $productStock,
'revenueSummary' => $revenueSummary,
'monthlyRevenue' => $monthlyRevenue,
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,
'revenueByPaymentType' => $revenueByPaymentType,
'expenseSummary' => $expenseSummary,
'monthlyExpense' => $monthlyExpense,
'busyHours' => $busyHours,
'profitMetrics' => $profitMetrics,
'topSuppliers' => $topSuppliers,
'topCustomers' => $topCustomers,
'topProducts' => $topProducts,
'marketingSales' => $marketingSales,
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Services\DashboardService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private DashboardService $service
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
$attendance = $this->service->getAttendanceStats();
$revenueSummary = $this->service->getRevenueSummary();
$expenseSummary = $this->service->getExpenseSummary();
$orderStats = $this->service->getOrderStats();
$todayAttendance = $this->service->getTodayAttendance($user);
$isOnLeave = $this->service->isOnLeave($user);
$canCheckIn = $user->employee !== null;
return Inertia::render('dashboard', [
'attendance' => $attendance,
'revenueSummary' => $revenueSummary,
'expenseSummary' => $expenseSummary,
'orderStats' => $orderStats,
'todayAttendance' => $todayAttendance,
'isOnLeave' => $isOnLeave,
'canCheckIn' => $canCheckIn,
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -33,7 +34,7 @@ protected function casts(): array
protected function formattedAttendanceDate(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? \Carbon\Carbon::parse($value)->translatedFormat('l, d F Y') : null,
get: fn ($value) => $value ? Carbon::parse($value)->translatedFormat('l, d F Y') : null,
);
}

View File

@ -45,7 +45,7 @@ protected function casts(): array
protected function channelLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->channel->label(),
get: fn () => $this->channel?->label(),
);
}
@ -73,14 +73,14 @@ protected function formattedNegoPrice(): Attribute
protected function paymentTypeLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->payment_type->label(),
get: fn () => $this->payment_type?->label(),
);
}
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
get: fn () => $this->status?->label(),
);
}

View File

@ -0,0 +1,476 @@
<?php
namespace App\Services;
use App\Enums\OrderStatus;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\RawMaterialPrice;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AnalysisService
{
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();
while ($current->lte($end)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$present = Attendance::whereBetween('attendance_date', [$start, $end])->count();
$leaveDays = 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;
return $carry + max(0, $days);
}, 0);
$absent = max(0, $workingDays - $present - $leaveDays);
return [
'total_employees' => $totalEmployees,
'present' => $present,
'absent' => $absent,
'on_leave' => $leaveDays,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
public function getMyAttendance(User $user, ?string $startDate, ?string $endDate): ?array
{
$employee = $user->employee;
if (! $employee) {
return null;
}
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
$workingDays = 0;
$current = $start->copy();
while ($current->lte($end)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$present = Attendance::where('employee_id', $employee->id)
->whereBetween('attendance_date', [$start, $end])
->count();
$leaveDays = LeaveRequest::approved()
->where('employee_id', $employee->id)
->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;
return $carry + max(0, $days);
}, 0);
$absent = max(0, $workingDays - $present - $leaveDays);
return [
'total_days' => $workingDays,
'present_days' => $present,
'absent_days' => $absent,
'leave_days' => $leaveDays,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
public function getCashOverview(): array
{
$cashAccount = CashAccount::first();
if (! $cashAccount) {
return [
'total_balance' => 0,
'total_transactions' => 0,
'total_deposit' => 0,
'total_withdrawal' => 0,
];
}
$transactions = $cashAccount->cashTransactions();
return [
'total_balance' => $cashAccount->balance,
'total_transactions' => (clone $transactions)->count(),
'total_deposit' => (clone $transactions)->where('type', 'deposit')->sum('amount'),
'total_withdrawal' => (clone $transactions)->where('type', 'withdrawal')->sum('amount'),
];
}
public function getRawMaterialStock(): array
{
$prices = RawMaterialPrice::select('stock', 'price')
->with('rawMaterial:id,unit')
->get();
$totalStock = $prices->sum('stock');
$totalValue = $prices->sum(fn ($p) => $p->stock * $p->price);
$byUnit = [
'yard' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'yard')->sum('stock'),
'meter' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'meter')->sum('stock'),
'kilogram' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'kg')->sum('stock'),
];
return [
'total_stock' => $totalStock,
'total_value' => $totalValue,
'by_unit' => $byUnit,
];
}
public function getProductStock(): array
{
$variants = ProductVariant::select('stock', 'reject_stock', 'retail_stock')
->with('product:id,name')
->get();
$totalStock = $variants->sum('stock');
$totalReject = $variants->sum('reject_stock');
$totalRetail = $variants->sum('retail_stock');
$totalValue = $variants->sum(function ($v) {
$retailPrice = $v->productPrices()->where('type', 'retail')->first()?->price ?? 0;
return $v->stock * $retailPrice;
});
return [
'total_stock' => $totalStock,
'total_reject' => $totalReject,
'total_retail' => $totalRetail,
'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'),
];
}
public function getRevenueSummary(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$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(total_amount), 0) - COALESCE(SUM(total_amount), 0) as total_marketplace_fees')
->selectRaw('COALESCE(SUM(discount), 0) as total_deduction')
->first();
return [
'total_revenue' => (int) $stats->total_revenue,
'total_discount' => (int) $stats->total_discount,
'total_marketplace_fees' => (int) $stats->total_marketplace_fees,
'total_deduction' => (int) $stats->total_deduction,
'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
];
}
public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$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('0 as net_warehouse')
->selectRaw('0 as net_retail')
->selectRaw('COALESCE(SUM(discount), 0) as deduction')
->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();
return $monthly->toArray();
}
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$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")
->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();
return $monthly->toArray();
}
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$data = (clone $query)
->select('payment_type')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn ($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,
]);
return $data->toArray();
}
public function getExpenseSummary(?string $startDate, ?string $endDate): array
{
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate);
$advanceQuery = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceQuery, $startDate, $endDate);
$purchaseQuery = Purchase::query();
$this->applyDateFilter($purchaseQuery, $startDate, $endDate);
$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,
];
}
public function getMonthlyExpense(?string $startDate, ?string $endDate): array
{
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate);
$expenseByMonth = (clone $expenseMonthly)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$purchaseMonthly = Purchase::query();
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate);
$purchaseByMonth = (clone $purchaseMonthly)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total), 0) as purchase')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceMonthly, $startDate, $endDate);
$advanceByMonth = (clone $advanceMonthly)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = collect();
foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) {
foreach ($data as $month => $row) {
if (! $allMonths->has($month)) {
$allMonths[$month] = ['month' => $month, 'total' => 0, 'purchase' => 0, 'expense' => 0, 'advance' => 0];
}
}
}
foreach ($allMonths as $month => &$row) {
$row['purchase'] = (int) ($purchaseByMonth[$month]['purchase'] ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]['expense'] ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]['advance'] ?? 0);
$row['total'] = $row['purchase'] + $row['expense'] + $row['advance'];
}
return array_values($allMonths->toArray());
}
public function getBusyHours(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$hours = range(0, 23);
$hourCounts = (clone $query)
->selectRaw('HOUR(created_at) as hour')
->selectRaw('COUNT(*) as orders')
->groupBy(DB::raw('HOUR(created_at)'))
->pluck('orders', 'hour')
->toArray();
return array_map(function ($h) use ($hourCounts) {
return [
'hour' => sprintf('%02d:00', $h),
'orders' => (int) ($hourCounts[$h] ?? 0),
];
}, $hours);
}
public function getProfitMetrics(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(cogs), 0) as hpp')
->first();
$totalProductsSold = (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->sum('order_items.quantity');
$grossProfit = $stats->total_revenue - $stats->hpp;
$netProfit = $grossProfit;
$profitMargin = $stats->total_revenue > 0 ? round(($netProfit / $stats->total_revenue) * 100, 1) : 0;
$aov = $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0;
$itemsPerTransaction = $stats->total_orders > 0 ? round($totalProductsSold / $stats->total_orders, 1) : 0;
return [
'total_orders' => (int) $stats->total_orders,
'total_products_sold' => (int) $totalProductsSold,
'hpp' => (int) $stats->hpp,
'gross_profit' => (int) $grossProfit,
'net_profit' => (int) $netProfit,
'profit_margin' => $profitMargin,
'aov' => $aov,
'items_per_transaction' => $itemsPerTransaction,
];
}
public function getTopSuppliers(?string $startDate, ?string $endDate): array
{
$query = Purchase::query();
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
->select('suppliers.name')
->selectRaw('COALESCE(SUM(purchases.total), 0) as total_amount')
->selectRaw('COUNT(*) as purchase_count')
->groupBy('suppliers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopCustomers(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_amount')
->selectRaw('COUNT(*) as order_count')
->groupBy('customers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopProducts(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
->select('products.name')
->selectRaw('SUM(order_items.quantity) as total_qty')
->selectRaw('COALESCE(SUM(order_items.subtotal), 0) as total_revenue')
->groupBy('products.name')
->orderByDesc('total_qty')
->limit(5)
->get()
->toArray();
}
public function getMarketingSales(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)
->whereNotNull('orders.marketing_id');
$this->applyDateFilter($query, $startDate, $endDate);
return (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')
->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')
->get()
->toArray();
}
private function applyDateFilter($query, ?string $startDate, ?string $endDate): void
{
if ($startDate) {
$query->whereDate('created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('created_at', '<=', $endDate);
}
}
}

View File

@ -0,0 +1,208 @@
<?php
namespace App\Services;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\User;
use Carbon\Carbon;
class DashboardService
{
public function getAttendanceStats(): array
{
$now = Carbon::now();
$startOfMonth = $now->copy()->startOfMonth();
$endOfMonth = $now->copy()->endOfMonth();
$workingDays = 0;
$current = $startOfMonth->copy();
while ($current->lte($endOfMonth)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$present = Attendance::whereYear('attendance_date', $now->year)
->whereMonth('attendance_date', $now->month)
->count();
$leaveDays = LeaveRequest::approved()
->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth)
->get()
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
return $carry + max(0, $days);
}, 0);
$absent = max(0, $workingDays - $present - $leaveDays);
return [
'total_employees' => $totalEmployees,
'present' => $present,
'absent' => $absent,
'on_leave' => $leaveDays,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
public function getRevenueSummary(): array
{
$stats = Order::where('status', OrderStatus::COMPLETED)
->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(total_amount), 0) - COALESCE(SUM(total_amount), 0) as total_marketplace_fees')
->selectRaw('COALESCE(SUM(discount), 0) as total_deduction')
->first();
return [
'total_revenue' => (int) $stats->total_revenue,
'total_discount' => (int) $stats->total_discount,
'total_marketplace_fees' => (int) $stats->total_marketplace_fees,
'total_deduction' => (int) $stats->total_deduction,
'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
];
}
public function getExpenseSummary(): array
{
$expenses = Expense::selectRaw('COALESCE(SUM(amount), 0) as total')->first();
$cashAdvanceTotal = EmployeeAdvance::where('status', 'paid')
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
$expenseTotal = Expense::selectRaw('COALESCE(SUM(amount), 0) as total')->first();
return [
'total' => (int) $expenses->total,
'purchase_total' => 0,
'expense_total' => (int) $expenseTotal->total,
'advance_total' => (int) ($cashAdvanceTotal->total ?? 0),
];
}
public function getOrderStats(): array
{
$baseQuery = Order::where('status', OrderStatus::COMPLETED);
$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 = Order::where('status', OrderStatus::COMPLETED)
->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()
->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,
];
}
public function getTodayAttendance(User $user): ?array
{
$employee = $user->employee;
if (! $employee) {
return null;
}
$attendance = Attendance::where('employee_id', $employee->id)
->where('attendance_date', now()->toDateString())
->first();
if (! $attendance) {
return null;
}
return [
'id' => $attendance->id,
'attendance_date' => $attendance->attendance_date,
'check_in_at' => $attendance->check_in_at,
'check_out_at' => $attendance->check_out_at,
'check_in_photo' => null,
'check_out_photo' => null,
'check_in_latitude' => $attendance->check_in_latitude,
'check_in_longitude' => $attendance->check_in_longitude,
'check_out_latitude' => $attendance->check_out_latitude,
'check_out_longitude' => $attendance->check_out_longitude,
'work_duration_minutes' => $attendance->work_duration_minutes,
];
}
public function isOnLeave(User $user): bool
{
$employee = $user->employee;
if (! $employee) {
return false;
}
return LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', now()->toDateString())
->where('end_date', '>=', now()->toDateString())
->exists();
}
}

View File

@ -15,7 +15,7 @@ public function run(): void
$permissions = [
'dashboard' => ['view', 'attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status', 'cash'],
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'marketing_sales', 'raw_materials'],
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'],
'employees' => ['view', 'create', 'update', 'delete', 'toggle_status', 'reset_password'],
'attendances' => ['view', 'create', 'delete', 'manage'],
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
@ -60,7 +60,7 @@ public function run(): void
$developerOwnerPerms = array_values(array_filter(
$allPermissions,
fn($p) => ! in_array($p, $excludedFromDeveloperOwner, true)
fn ($p) => ! in_array($p, $excludedFromDeveloperOwner, true)
));
$rolePermissions = [
@ -90,6 +90,7 @@ public function run(): void
'analysis.profit_gross',
'analysis.top_customers',
'analysis.top_products',
'analysis.top_suppliers',
'analysis.marketing_sales',
'stok_opnames.view',

3269
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,7 @@
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"recharts": "^3.10.1",
"shadcn": "^4.16.0",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.1",

View File

@ -1,3 +1,36 @@
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useCan } from '@/hooks/use-can';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import analysis from '@/routes/admin/analysis';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
import { Link, router } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react';
import {
@ -24,38 +57,6 @@ import {
Wallet,
} from 'lucide-react';
import React from 'react';
import AppLogo from '@/components/app-logo';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useCan } from '@/hooks/use-can';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { dashboard } from '@/routes';
import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts';
import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances';
import { index as expensesIndex } from '@/routes/admin/finance/expenses';
import { current as payrollCurrent } from '@/routes/admin/finance/payroll-periods';
import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
import { index as employeesIndex } from '@/routes/admin/hr/employees';
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products';
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
type NavMenuItem = { title: string; href: string; icon: LucideIcon; permission?: string | string[] };
@ -67,8 +68,9 @@ const dasborItem: NavMenuItem = {
const analisaItem: NavMenuItem = {
title: 'Analisa',
href: '#',
href: analysis.index.url(),
icon: BarChart3,
permission: 'analysis.view',
};
const masterItems: NavMenuItem[] = [
@ -112,19 +114,19 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
const filtered = items.filter((item) => {
if (!item.permission) {
return true;
}
return true;
}
if (Array.isArray(item.permission)) {
return canAny(...item.permission);
}
return canAny(...item.permission);
}
return can(item.permission);
});
if (filtered.length === 0) {
return null;
}
return null;
}
return (
<SidebarGroup>
@ -152,6 +154,7 @@ return null;
export function AppSidebar() {
const { isCurrentUrl } = useCurrentUrl();
const { isMobile, setOpenMobile } = useSidebar();
const { can } = useCan();
React.useEffect(() => {
if (!isMobile) {
@ -199,17 +202,20 @@ export function AppSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={{ children: analisaItem.title }}
>
<Link href={analisaItem.href} prefetch>
<analisaItem.icon />
<span>{analisaItem.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{can(analisaItem.permission as string) && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={isCurrentUrl(analisaItem.href)}
tooltip={{ children: analisaItem.title }}
>
<Link href={analisaItem.href} prefetch>
<analisaItem.icon />
<span>{analisaItem.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>

View File

@ -0,0 +1,44 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
type StatItem = {
label: string;
value: string | number;
};
type StatCardProps = {
title: string;
icon: LucideIcon;
mainLabel?: string;
mainValue: string | number;
subLabel?: string;
items?: StatItem[];
cols?: 2 | 3 | 4;
};
export function StatCard({ title, icon: Icon, mainLabel, mainValue, subLabel, items = [], cols = 3 }: StatCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{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>}
{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) => (
<div key={index}>
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-sm font-medium">{item.value}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,838 @@
import { Head, router } from '@inertiajs/react';
import {
Banknote,
Package,
ShoppingCart,
TrendingUp,
UserCheck,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { StatCard } from '@/components/card/stat-card';
import { DatePicker } from '@/components/date-picker';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
type Filters = {
start_date?: string;
end_date?: string;
};
type AnalysisProps = {
filters: Filters;
attendance: {
total_employees: number;
percentage: number;
present: number;
absent: number;
on_leave: number;
};
myAttendance: {
total_days: number;
present_days: number;
absent_days: number;
leave_days: number;
percentage: number;
} | null;
isManager: boolean;
cashOverview: {
total_balance: number;
total_transactions: number;
total_deposit: number;
total_withdrawal: number;
};
rawMaterialStock: {
total_stock: number;
total_value: number;
by_unit: {
yard: number;
meter: number;
kilogram: number;
};
};
productStock: {
total_stock: number;
total_reject: number;
total_retail: number;
total_value: number;
total_products: number;
total_variants: number;
total_categories: number;
};
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_deduction: number;
total_orders: number;
avg_order: number;
};
monthlyRevenue: Array<{
month: string;
total: number;
net: number;
net_warehouse: number;
net_retail: number;
deduction: number;
}>;
monthlyRevenueByChannel: Array<{
month: string;
store: number;
shopee: number;
tiktok: number;
}>;
revenueByPaymentType: Array<{
payment_type: string;
label: string;
total: number;
}>;
expenseSummary: {
total: number;
purchase_total: number;
expense_total: number;
advance_total: number;
};
monthlyExpense: Array<{
month: string;
total: number;
purchase: number;
expense: number;
advance: number;
}>;
busyHours: Array<{
hour: string;
orders: number;
}>;
profitMetrics: {
total_orders: number;
total_products_sold: number;
hpp: number;
gross_profit: number;
net_profit: number;
profit_margin: number;
aov: number;
items_per_transaction: number;
};
topSuppliers: Array<{
name: string;
total_amount: number;
purchase_count: number;
}>;
topCustomers: Array<{
name: string;
total_amount: number;
order_count: number;
}>;
topProducts: Array<{
name: string;
total_qty: number;
total_revenue: number;
}>;
marketingSales: Array<{
marketing_name: string;
total_orders: number;
total_products_sold: number;
total_revenue: number;
total_subtotal: number;
total_discount: number;
avg_order: number;
}>;
};
const REVENUE_COLORS: Record<string, string> = {
total: '#60a5fa',
net: '#22c55e',
net_warehouse: '#10b981',
net_retail: '#06b6d4',
deduction: '#f97316',
};
const EXPENSE_COLORS: Record<string, string> = {
total: '#60a5fa',
purchase: '#f97316',
expense: '#a855f7',
advance: '#ef4444',
};
const CHANNEL_COLORS: Record<string, string> = {
store: '#22c55e',
shopee: '#ee4d2d',
tiktok: '#000000',
};
const PAYMENT_COLORS: Record<string, string> = {
cash: '#22c55e',
transfer: '#60a5fa',
qris: '#a855f7',
marketplace: '#f97316',
};
type TooltipProps = {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color?: string;
payload: Record<string, unknown>;
}>;
label?: string;
};
function BarTooltip({ active, payload, label }: TooltipProps) {
if (!active || !payload?.length) {
return null;
}
return (
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
<p className="font-medium">{label}</p>
{payload.map((item, i) => (
<p key={i} className="flex items-center gap-1 text-sm">
<span className="size-2 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground">{item.name}</span>
<span className="ml-auto font-medium tabular-nums">
{typeof item.value === 'number' && item.value > 1000
? `Rp${formatRupiah(item.value)}`
: item.value}
</span>
</p>
))}
</div>
);
}
function DonutTooltip({ active, payload }: TooltipProps) {
if (!active || !payload?.length) {
return null;
}
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
<p className="flex items-center gap-1 text-sm">
<span className="size-2 rounded-full" style={{ backgroundColor: data.color as string }} />
<span className="text-muted-foreground">{(data.label as string) ?? ''}</span>
<span className="ml-auto font-medium tabular-nums">
{data.total !== undefined ? `Rp${formatRupiah(data.total as number)}` : (data.value as number)?.toLocaleString('id-ID')}
</span>
</p>
</div>
);
}
export default function Analysis({
filters: initialFilters,
attendance,
myAttendance,
isManager,
cashOverview,
rawMaterialStock,
productStock,
revenueSummary,
monthlyRevenue,
monthlyRevenueByChannel,
revenueByPaymentType,
expenseSummary,
monthlyExpense,
busyHours,
profitMetrics,
topSuppliers,
topCustomers,
topProducts,
marketingSales,
}: 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 hasActiveFilters = !!startDate || !!endDate;
const formatDate = useCallback((date: Date): string => date.toISOString().split('T')[0], []);
const applyFilters = useCallback(() => {
router.get(
'/admin/analysis',
{ start_date: startDate, end_date: endDate },
{ preserveState: true, preserveScroll: true },
);
}, [startDate, endDate]);
const clearFilters = useCallback(() => {
setStartDate('');
setEndDate('');
setSelectedPreset('');
}, []);
useEffect(() => {
const timer = setTimeout(applyFilters, 500);
return () => clearTimeout(timer);
}, [startDate, endDate, applyFilters]);
const onPresetChange = useCallback((value: string) => {
setSelectedPreset(value);
const now = new Date();
let start: Date;
switch (value) {
case 'today':
start = new Date(now);
break;
case 'week':
start = new Date(now);
start.setDate(now.getDate() - now.getDay() + 1);
break;
case 'month':
start = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'year':
start = new Date(now.getFullYear(), 0, 1);
break;
default:
return;
}
setStartDate(formatDate(start));
setEndDate(formatDate(now));
}, [formatDate]);
const revenueByChannelData = useMemo(() => {
const totals = monthlyRevenueByChannel.reduce(
(acc, item) => ({
store: acc.store + (item.store ?? 0),
shopee: acc.shopee + (item.shopee ?? 0),
tiktok: acc.tiktok + (item.tiktok ?? 0),
}),
{ store: 0, shopee: 0, tiktok: 0 },
);
return [
{ channel: 'store', label: 'Toko', total: totals.store, color: CHANNEL_COLORS.store },
{ channel: 'shopee', label: 'Shopee', total: totals.shopee, color: CHANNEL_COLORS.shopee },
{ channel: 'tiktok', label: 'TikTok', total: totals.tiktok, color: CHANNEL_COLORS.tiktok },
];
}, [monthlyRevenueByChannel]);
const peakHour = useMemo(() => {
if (busyHours.length === 0) {
return { hour: '-', orders: 0 };
}
return busyHours.reduce((max, item) => (item.orders > max.orders ? item : max), busyHours[0]);
}, [busyHours]);
const visibleExpenseCharts = useMemo(() => {
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
if (hasAnyRole(['owner', 'developer'])) {
return charts;
}
return charts.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 };
}
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 };
}
if (hasRole('marketing')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, totalOrder: 4, topProducts: 5, topCustomers: 6 };
}
return {};
}, [hasAnyRole, hasRole]);
return (
<>
<Head title="Analisa" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Analisa</h2>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={selectedPreset} onValueChange={onPresetChange}>
<SelectTrigger className="h-9 w-[140px]">
<SelectValue placeholder="Filter Cepat" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="today">Hari Ini</SelectItem>
<SelectItem value="week">Minggu Ini</SelectItem>
<SelectItem value="month">Bulan Ini</SelectItem>
<SelectItem value="year">Tahun Ini</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Mulai:</span>
<DatePicker value={startDate} onChange={(d) => setStartDate(d ? formatDate(d) : '')} className="w-[170px]" placeholder="Pilih tanggal" />
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Sampai:</span>
<DatePicker value={endDate} onChange={(d) => setEndDate(d ? formatDate(d) : '')} className="w-[170px]" placeholder="Pilih tanggal" />
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-9 px-3">
Reset Filter
</Button>
)}
</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 },
]}
/>
)}
{can('analysis.attendance') && !isManager && myAttendance && (
<StatCard
title="Kehadiran Saya"
icon={UserCheck}
mainLabel="Hari Kerja"
mainValue={myAttendance.total_days}
subLabel={`${myAttendance.percentage}% hadir`}
items={[
{ label: 'Hadir', value: myAttendance.present_days },
{ label: 'Tidak Hadir', value: myAttendance.absent_days },
{ label: 'Cuti', value: myAttendance.leave_days },
]}
/>
)}
{can('analysis.cash') && (
<StatCard
title="Kas Toko"
icon={Banknote}
mainLabel="Total Saldo"
mainValue={`Rp${formatRupiah(cashOverview.total_balance)}`}
subLabel={`${cashOverview.total_transactions} transaksi`}
items={[
{ label: 'Deposit', value: `Rp${formatRupiah(cashOverview.total_deposit)}` },
{ label: 'Withdrawal', value: `Rp${formatRupiah(cashOverview.total_withdrawal)}` },
]}
cols={2}
/>
)}
{can('analysis.raw_materials') && (
<StatCard
title="Bahan Baku"
icon={Package}
mainLabel="Total Stok"
mainValue={rawMaterialStock.total_stock.toLocaleString('id-ID')}
subLabel={`Rp${formatRupiah(rawMaterialStock.total_value)}`}
items={[
{ label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' },
{ label: 'Meter', value: rawMaterialStock.by_unit?.meter?.toLocaleString('id-ID') ?? '0' },
{ label: 'Kg', value: rawMaterialStock.by_unit?.kilogram?.toLocaleString('id-ID') ?? '0' },
]}
/>
)}
{can('analysis.product_stock') && (
<StatCard
title="Stok Produk"
icon={ShoppingCart}
mainLabel="Total Stok"
mainValue={(productStock.total_stock + productStock.total_reject + productStock.total_retail).toLocaleString('id-ID')}
subLabel={`Rp${formatRupiah(productStock.total_value)}`}
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') },
]}
/>
)}
</div>
{can('analysis.revenue') && (
<Card className="py-4 sm: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">
<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 === 'deduction' ? revenueSummary.total_deduction : 0)}</span>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{monthlyRevenue.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={monthlyRevenue}>
<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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
)}
</CardContent>
</Card>
)}
{can('analysis.revenue') && (
<div className="grid gap-4 md:grid-cols-2" style={{ order: sectionOrder.revenueByChannel ?? 99 }}>
<Card className="py-4 sm: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 py-5 sm:py-6">
<CardTitle>Pendapatan per Channel</CardTitle>
</div>
<div className="flex flex-col sm:flex-row">
{revenueByChannelData.map((item) => (
<div key={item.channel} 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-4">
<span className="text-xs text-muted-foreground">{item.label}</span>
<span className="text-sm">Rp{formatRupiah(item.total)}</span>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{revenueByChannelData.length > 0 ? (
<div className="mx-auto aspect-square max-h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={revenueByChannelData} dataKey="total" nameKey="label" cx="50%" cy="50%" outerRadius={100} innerRadius={60} strokeWidth={2} stroke="#374151">
{revenueByChannelData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip content={<DonutTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
)}
</CardContent>
</Card>
<Card className="py-4 sm: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 py-5 sm:py-6">
<CardTitle>Pendapatan per Pembayaran</CardTitle>
</div>
<div className="flex flex-col sm:flex-row">
{revenueByPaymentType.map((item) => (
<div key={item.payment_type} 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-4">
<span className="text-xs text-muted-foreground">{item.label}</span>
<span className="text-sm">Rp{formatRupiah(item.total)}</span>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{revenueByPaymentType.length > 0 ? (
<div className="mx-auto aspect-square max-h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={revenueByPaymentType.map((item) => ({
...item,
color: PAYMENT_COLORS[item.payment_type] ?? '#94a3b8',
}))}
dataKey="total"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={60}
strokeWidth={2}
stroke="#374151"
>
{revenueByPaymentType.map((entry, index) => (
<Cell key={index} fill={PAYMENT_COLORS[entry.payment_type] ?? '#94a3b8'} />
))}
</Pie>
<Tooltip content={<DonutTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
)}
</CardContent>
</Card>
</div>
)}
{can('analysis.expense') && (
<Card className="py-4 sm: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">
<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>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{monthlyExpense.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={monthlyExpense}>
<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="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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pengeluaran</div>
)}
</CardContent>
</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
title="Total Order"
icon={ShoppingCart}
mainLabel="Pesanan Selesai"
mainValue={profitMetrics.total_orders.toLocaleString('id-ID')}
items={[
{ label: 'Produk Terjual', value: profitMetrics.total_products_sold.toLocaleString('id-ID') },
{ label: 'Item/Transaksi', value: profitMetrics.items_per_transaction },
{ label: 'Rata-rata/Transaksi', value: `Rp${formatRupiah(profitMetrics.aov)}` },
]}
/>
</div>
)}
{can('analysis.marketing_sales') && (
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
<CardHeader>
<CardTitle>Penjualan Marketing</CardTitle>
<CardDescription>Rekap penjualan per marketing</CardDescription>
</CardHeader>
<CardContent>
{marketingSales.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Marketing</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Order</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Produk Terjual</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Pendapatan</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Subtotal</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Diskon</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Rata-rata Order</th>
</tr>
</thead>
<tbody>
{marketingSales.map((item, index) => (
<tr key={index} className="border-b last:border-0">
<td className="px-4 py-3 font-medium">{item.marketing_name}</td>
<td className="px-4 py-3 text-right tabular-nums">{item.total_orders.toLocaleString('id-ID')}</td>
<td className="px-4 py-3 text-right tabular-nums">{item.total_products_sold.toLocaleString('id-ID')} pcs</td>
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_revenue)}</td>
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_subtotal)}</td>
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.total_discount)}</td>
<td className="px-4 py-3 text-right tabular-nums">Rp{formatRupiah(item.avg_order)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex h-[150px] items-center justify-center text-muted-foreground">Belum ada data penjualan marketing</div>
)}
</CardContent>
</Card>
)}
{can('analysis.top_suppliers') && (
<Card style={{ order: sectionOrder.topSuppliers ?? 99 }}>
<CardHeader>
<CardTitle className="text-base">Top 5 Supplier</CardTitle>
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
</CardHeader>
<CardContent>
{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 }))}>
<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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data supplier</div>
)}
</CardContent>
</Card>
)}
{can('analysis.top_products') && (
<Card style={{ order: sectionOrder.topProducts ?? 99 }}>
<CardHeader>
<CardTitle className="text-base">Top 5 Produk</CardTitle>
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
</CardHeader>
<CardContent>
{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 }))}>
<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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data produk</div>
)}
</CardContent>
</Card>
)}
{can('analysis.top_customers') && (
<Card style={{ order: sectionOrder.topCustomers ?? 99 }}>
<CardHeader>
<CardTitle className="text-base">Top 5 Pelanggan</CardTitle>
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
</CardHeader>
<CardContent>
{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 }))}>
<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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pelanggan</div>
)}
</CardContent>
</Card>
)}
{can('analysis.busy_hours') && (
<Card style={{ order: sectionOrder.busyHours ?? 99 }}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Jam Sibuk Toko</CardTitle>
</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>
</div>
</CardHeader>
<CardContent>
{busyHours.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={busyHours}>
<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" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-muted-foreground">Belum ada data pesanan</div>
)}
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@ -1,27 +1,470 @@
import { Head } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { dashboard } from '@/routes';
import { Head, usePage } from '@inertiajs/react';
import {
Clock,
Moon,
Sunrise,
Sun,
TrendingDown,
TrendingUp,
UserCheck,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Pie, PieChart, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { AttendanceCard } from '@/components/card/attendance-card';
import { StatCard } from '@/components/card/stat-card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useCan } from '@/hooks/use-can';
import { formatRupiah } from '@/lib/rupiah';
type TodayAttendance = {
id: number;
attendance_date: string;
check_in_at: string | null;
check_out_at: string | null;
check_in_photo: string | null;
check_out_photo: string | null;
check_in_latitude: number;
check_in_longitude: number;
check_out_latitude: number | null;
check_out_longitude: number | null;
work_duration_minutes: number | null;
} | null;
type DashboardProps = {
attendance: {
total_employees: number;
percentage: number;
present: number;
absent: number;
on_leave: number;
};
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_deduction: number;
total_orders: number;
avg_order: number;
};
expenseSummary: {
total: number;
purchase_total: number;
expense_total: number;
advance_total: 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;
}>;
};
todayAttendance: TodayAttendance;
isOnLeave: boolean;
canCheckIn: boolean;
};
const CHART_COLORS = ['#60a5fa', '#f97316', '#22c55e', '#a855f7', '#ef4444'];
type CustomTooltipProps = {
active?: boolean;
payload?: Array<{
name: string;
value: number;
payload: {
label?: string;
name?: string;
count: number;
};
}>;
};
function ChartTooltip({ active, payload }: CustomTooltipProps) {
if (!active || !payload?.length) {
return null;
}
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background px-3 py-1.5 shadow-xl">
<p className="text-sm">
<span className="text-muted-foreground">{data.label ?? data.name ?? ''}</span>
<span className="ml-2 font-medium">{data.count.toLocaleString('id-ID')}</span>
</p>
</div>
);
}
export default function Dashboard({
attendance: attendanceStats,
revenueSummary,
expenseSummary,
orderStats,
todayAttendance,
isOnLeave,
canCheckIn,
}: DashboardProps) {
const { can } = useCan();
const { auth } = usePage().props as { auth: { user?: { username?: string } } };
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const greeting = useMemo(() => {
const hour = currentTime.getHours();
if (hour >= 4 && hour < 11) {
return { text: 'Selamat Pagi', icon: Sunrise };
}
if (hour >= 11 && hour < 15) {
return { text: 'Selamat Siang', icon: Sun };
}
if (hour >= 15 && hour < 18) {
return { text: 'Selamat Sore', icon: Sun };
}
return { text: 'Selamat Malam', icon: Moon };
}, [currentTime]);
const timeStr = currentTime.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const dateStr = currentTime.toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const visibleStatsCount = useMemo(() => {
let count = 0;
if (can('dashboard.attendance')) {
count++;
}
if (can('dashboard.revenue')) {
count++;
}
if (can('dashboard.expense')) {
count++;
}
return count;
}, [can]);
const statsGridClass = useMemo(() => {
if (visibleStatsCount === 1) {
return 'grid gap-4 grid-cols-1 md:max-w-md';
}
if (visibleStatsCount === 2) {
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
}
if (visibleStatsCount === 3) {
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
}
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
}, [visibleStatsCount]);
const visibleChartsCount = useMemo(() => {
let count = 0;
if (can('dashboard.orders_channel')) {
count++;
}
if (can('dashboard.orders_payment')) {
count++;
}
if (can('dashboard.orders_marketing')) {
count++;
}
if (can('dashboard.orders_status')) {
count++;
}
return count;
}, [can]);
const chartsGridClass = useMemo(() => {
if (visibleChartsCount === 1) {
return 'grid gap-4 grid-cols-1 md:max-w-xl';
}
if (visibleChartsCount === 2) {
return 'grid gap-4 grid-cols-1 md:grid-cols-2';
}
if (visibleChartsCount === 3) {
return 'grid gap-4 grid-cols-1 md:grid-cols-3';
}
return 'grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
}, [visibleChartsCount]);
const GreetingIcon = greeting.icon;
export default function Dashboard() {
return (
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<Head title="Dasbor" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<Card className="relative overflow-hidden">
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-background" />
<CardHeader className="relative">
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<GreetingIcon className="size-6 text-primary" />
</div>
<div>
<CardTitle className="text-2xl font-bold">
{greeting.text}, {auth.user?.username}!
</CardTitle>
<CardDescription className="mt-1">
Selamat datang di dasbor aplikasi.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="relative">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{dateStr}</span>
<span>-</span>
<span className="flex items-center gap-1">
<Clock className="size-3.5" />
{timeStr}
</span>
</div>
</CardContent>
</Card>
<AttendanceCard
todayAttendance={todayAttendance}
isOnLeave={isOnLeave}
canCheckIn={canCheckIn}
/>
{visibleStatsCount > 0 && (
<div className={statsGridClass}>
{can('dashboard.attendance') && (
<StatCard
title="Kehadiran"
icon={UserCheck}
mainLabel="Total Karyawan"
mainValue={attendanceStats.total_employees}
subLabel={`${attendanceStats.percentage}% hadir`}
items={[
{ label: 'Hadir', value: attendanceStats.present },
{ label: 'Tidak Hadir', value: attendanceStats.absent },
{ label: 'Cuti', value: attendanceStats.on_leave },
]}
/>
)}
{can('dashboard.revenue') && (
<StatCard
title="Pendapatan"
icon={TrendingUp}
mainLabel="Total"
mainValue={`Rp${formatRupiah(revenueSummary.total_revenue)}`}
subLabel={`${revenueSummary.total_orders} transaksi selesai`}
items={[
{
label: 'Bersih',
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_deduction)}`,
},
{
label: 'Potongan',
value: `Rp${formatRupiah(revenueSummary.total_marketplace_fees ?? 0)}`,
},
{
label: 'Diskon',
value: `Rp${formatRupiah(revenueSummary.total_discount)}`,
},
]}
/>
)}
{can('dashboard.expense') && (
<StatCard
title="Pengeluaran"
icon={TrendingDown}
mainLabel="Total"
mainValue={`Rp${formatRupiah(expenseSummary.total)}`}
items={[
{
label: 'Pengeluaran',
value: `Rp${formatRupiah(expenseSummary.expense_total)}`,
},
{
label: 'Kasbon',
value: `Rp${formatRupiah(expenseSummary.advance_total)}`,
},
]}
/>
)}
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
)}
{visibleChartsCount > 0 && (
<div className={chartsGridClass}>
{can('dashboard.orders_channel') && (
<DashboardDonutChart
title="Pesanan per Channel"
data={orderStats.by_channel.map((item, index) => ({
...item,
color: CHART_COLORS[index % CHART_COLORS.length],
}))}
dataKey="count"
nameKey="label"
/>
)}
{can('dashboard.orders_payment') && (
<DashboardDonutChart
title="Pesanan per Pembayaran"
data={orderStats.by_payment_type.map((item, index) => ({
...item,
color: CHART_COLORS[index % CHART_COLORS.length],
}))}
dataKey="count"
nameKey="label"
/>
)}
{can('dashboard.orders_marketing') && (
<DashboardDonutChart
title="Pesanan per Marketing"
data={orderStats.by_marketing.map((item, index) => ({
...item,
label: item.name,
color: CHART_COLORS[index % CHART_COLORS.length],
}))}
dataKey="count"
nameKey="label"
/>
)}
{can('dashboard.orders_status') && (
<DashboardDonutChart
title="Pesanan per Status"
data={orderStats.by_status.map((item, index) => ({
...item,
color: CHART_COLORS[index % CHART_COLORS.length],
}))}
dataKey="count"
nameKey="label"
/>
)}
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
)}
</div>
</>
);
}
type DonutChartItem = {
label: string;
count: number;
color: string;
};
type DashboardDonutChartProps = {
title: string;
data: DonutChartItem[];
dataKey: string;
nameKey: string;
};
function DashboardDonutChart({ title, data }: DashboardDonutChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{hasData ? (
<>
<div className="mx-auto aspect-square max-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={80}
innerRadius={50}
strokeWidth={2}
stroke="#374151"
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color ?? CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-3 flex flex-wrap justify-center gap-3">
{data.map((item, index) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: item.color ?? CHART_COLORS[index % CHART_COLORS.length] }}
/>
<span className="text-xs text-muted-foreground">{item.label}</span>
<span className="text-xs font-medium">{item.count}</span>
</div>
))}
</div>
</>
) : (
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
Belum ada data
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -23,9 +23,11 @@
use App\Http\Controllers\Admin\Master\RawMaterial\RawMaterialVariantController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\RoleController;
use App\Http\Controllers\AnalysisController;
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => inertia('welcome', [
Route::get('/', fn() => inertia('welcome', [
'seo' => [
'title' => 'DST Collection - DST Punya Gaya',
'description' => 'DST Collection - DST Punya Gaya. Toko fashion terpercaya dengan koleksi terlengkap.',
@ -35,7 +37,9 @@
]))->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'dashboard')->name('dashboard');
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('admin/analysis', [AnalysisController::class, 'index'])->name('admin.analysis.index')->middleware('permission:analysis.view');
Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:categories.view|categories.create|categories.update|categories.delete');
@ -129,4 +133,4 @@
});
});
require __DIR__.'/settings.php';
require __DIR__ . '/settings.php';