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:
parent
7a1b107ce5
commit
c6a87a64c9
67
app/Http/Controllers/AnalysisController.php
Normal file
67
app/Http/Controllers/AnalysisController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/DashboardController.php
Normal file
38
app/Http/Controllers/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
476
app/Services/AnalysisService.php
Normal file
476
app/Services/AnalysisService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
app/Services/DashboardService.php
Normal file
208
app/Services/DashboardService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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
3269
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
44
resources/js/components/card/stat-card.tsx
Normal file
44
resources/js/components/card/stat-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
838
resources/js/pages/admin/analysis/index.tsx
Normal file
838
resources/js/pages/admin/analysis/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user