feat: introduce AnalysisService and refactor AnalysisController to enhance data retrieval for analysis metrics
This commit is contained in:
parent
e90ff1abe0
commit
3f510f3d58
@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\System\DashboardService;
|
||||
use App\Services\System\AnalysisService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@ -12,7 +12,7 @@
|
||||
class AnalysisController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardService $dashboardService,
|
||||
private readonly AnalysisService $analysisService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@ -25,18 +25,19 @@ public function index(Request $request): Response
|
||||
'start_date' => $request->query('start_date', ''),
|
||||
'end_date' => $request->query('end_date', ''),
|
||||
],
|
||||
'rawMaterialStock' => $this->dashboardService->getRawMaterialStock(),
|
||||
'productStock' => $this->dashboardService->getProductStock(),
|
||||
'kasbonSummary' => $this->dashboardService->getKasbonSummary($startDate, $endDate),
|
||||
'leaveRequestSummary' => $this->dashboardService->getLeaveRequestSummary($startDate, $endDate),
|
||||
'lowStockProducts' => $this->dashboardService->getLowStockProducts(),
|
||||
'lowStockMaterials' => $this->dashboardService->getLowStockMaterials(),
|
||||
'attendanceToday' => $this->dashboardService->getAttendanceToday($startDate, $endDate),
|
||||
'cashAccounts' => $this->dashboardService->getCashAccounts(),
|
||||
'cashSummary' => $this->dashboardService->getCashSummary($startDate, $endDate),
|
||||
'purchaseSummary' => $this->dashboardService->getPurchaseSummary($startDate, $endDate),
|
||||
'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startDate, $endDate),
|
||||
'revenueSummary' => $this->dashboardService->getRevenueSummary($startDate, $endDate),
|
||||
'attendance' => $this->analysisService->getAttendance(),
|
||||
'cashOverview' => $this->analysisService->getCashOverview(),
|
||||
'rawMaterialStock' => $this->analysisService->getRawMaterialStock(),
|
||||
'productStock' => $this->analysisService->getProductStock(),
|
||||
'revenueSummary' => $this->analysisService->getRevenueSummary($startDate, $endDate),
|
||||
'monthlyRevenue' => $this->analysisService->getMonthlyRevenue($startDate, $endDate),
|
||||
'expenseSummary' => $this->analysisService->getExpenseSummary($startDate, $endDate),
|
||||
'monthlyExpense' => $this->analysisService->getMonthlyExpense($startDate, $endDate),
|
||||
'busyHours' => $this->analysisService->getBusyHours($startDate, $endDate),
|
||||
'profitMetrics' => $this->analysisService->getProfitMetrics($startDate, $endDate),
|
||||
'topSuppliers' => $this->analysisService->getTopSuppliers($startDate, $endDate),
|
||||
'topCustomers' => $this->analysisService->getTopCustomers($startDate, $endDate),
|
||||
'topProducts' => $this->analysisService->getTopProducts($startDate, $endDate),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,10 +21,6 @@ public function index(): Response
|
||||
'revenueSummary' => $this->dashboardService->getRevenueSummary(),
|
||||
'expenseSummary' => $this->dashboardService->getExpenseSummary(),
|
||||
|
||||
'topSuppliers' => $this->dashboardService->getTopSuppliers(),
|
||||
'topCustomers' => $this->dashboardService->getTopCustomers(),
|
||||
'topProducts' => $this->dashboardService->getTopProducts(),
|
||||
|
||||
'orderStats' => $this->dashboardService->getOrderStats(),
|
||||
]);
|
||||
}
|
||||
|
||||
466
app/Services/System/AnalysisService.php
Normal file
466
app/Services/System/AnalysisService.php
Normal file
@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Employee;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\Expense;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AnalysisService
|
||||
{
|
||||
public function getAttendance(): array
|
||||
{
|
||||
$totalEmployees = Employee::query()->count();
|
||||
$today = Carbon::today();
|
||||
|
||||
$present = Attendance::query()
|
||||
->where('attendance_date', $today)
|
||||
->distinct('employee_id')
|
||||
->count('employee_id');
|
||||
|
||||
$onLeave = LeaveRequest::query()
|
||||
->approved()
|
||||
->where('start_date', '<=', $today)
|
||||
->where('end_date', '>=', $today)
|
||||
->distinct('employee_id')
|
||||
->count('employee_id');
|
||||
|
||||
$absent = max(0, $totalEmployees - $present - $onLeave);
|
||||
|
||||
return [
|
||||
'total_employees' => $totalEmployees,
|
||||
'percentage' => $totalEmployees > 0 ? round($present / $totalEmployees * 100) : 0,
|
||||
'present' => $present,
|
||||
'absent' => $absent,
|
||||
'on_leave' => $onLeave,
|
||||
];
|
||||
}
|
||||
|
||||
public function getCashOverview(): array
|
||||
{
|
||||
$totalBalance = CashAccount::query()->sum('balance');
|
||||
|
||||
$summary = CashTransaction::query()
|
||||
->whereDate('created_at', Carbon::today())
|
||||
->selectRaw("
|
||||
COUNT(*) as total_transactions,
|
||||
COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as total_deposit,
|
||||
COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as total_withdrawal
|
||||
")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_balance' => (int) $totalBalance,
|
||||
'total_transactions' => (int) ($summary->total_transactions ?? 0),
|
||||
'total_deposit' => (int) ($summary->total_deposit ?? 0),
|
||||
'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTopSuppliers(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
return Purchase::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate]))
|
||||
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
|
||||
->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count')
|
||||
->groupBy('suppliers.id', 'suppliers.name')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->name,
|
||||
'total_amount' => (int) $item->total_amount,
|
||||
'purchase_count' => (int) $item->purchase_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getTopCustomers(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
return Order::query()
|
||||
->completed()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->join('customers', 'orders.customer_id', '=', 'customers.id')
|
||||
->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count')
|
||||
->groupBy('customers.id', 'customers.name')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->name,
|
||||
'total_amount' => (int) $item->total_amount,
|
||||
'order_count' => (int) $item->order_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getRevenueSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$revenueSummary = Order::query()
|
||||
->completed()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders, AVG(total_amount) as avg_order')
|
||||
->first();
|
||||
|
||||
$totalMarketplaceFees = Order::query()
|
||||
->completed()
|
||||
->whereNotNull('marketplace_settings_snapshot')
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->get()
|
||||
->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0));
|
||||
|
||||
return [
|
||||
'total_revenue' => (int) ($revenueSummary->total_revenue ?? 0),
|
||||
'total_discount' => (int) ($revenueSummary->total_discount ?? 0),
|
||||
'total_marketplace_fees' => $totalMarketplaceFees,
|
||||
'total_potongan' => (int) ($revenueSummary->total_discount ?? 0) + $totalMarketplaceFees,
|
||||
'total_shipping' => (int) ($revenueSummary->total_shipping ?? 0),
|
||||
'total_orders' => (int) ($revenueSummary->total_orders ?? 0),
|
||||
'avg_order' => (int) ($revenueSummary->avg_order ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$query = Order::query()->completed();
|
||||
$feeQuery = Order::query()->completed()->whereNotNull('marketplace_settings_snapshot');
|
||||
|
||||
if ($startDate && $endDate) {
|
||||
$query->whereBetween('orders.created_at', [$startDate, $endDate]);
|
||||
$feeQuery->whereBetween('orders.created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
$monthlyData = $query
|
||||
->selectRaw("
|
||||
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
|
||||
SUM(total_amount) as total_revenue,
|
||||
SUM(discount) as total_discount,
|
||||
SUM(shipping_cost) as total_shipping
|
||||
")
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get();
|
||||
|
||||
$monthlyFees = $feeQuery
|
||||
->selectRaw("DATE_FORMAT(orders.created_at, '%Y-%m') as month_key")
|
||||
->get()
|
||||
->groupBy('month_key')
|
||||
->map(fn ($orders) => $orders->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0)));
|
||||
|
||||
if ($monthlyData->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$start = $startDate ?? Carbon::parse($monthlyData->first()->month_key.'-01');
|
||||
$end = $endDate ?? Carbon::parse($monthlyData->last()->month_key.'-01')->endOfMonth();
|
||||
|
||||
$result = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
while ($current->lte($end)) {
|
||||
$key = $current->format('Y-m');
|
||||
$monthLabel = $current->locale('id')->translatedFormat('M Y');
|
||||
|
||||
$revenue = $monthlyData->firstWhere('month_key', $key);
|
||||
$fees = $monthlyFees->get($key, 0);
|
||||
$discount = (int) ($revenue->total_discount ?? 0);
|
||||
$potongan = $discount + $fees;
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthLabel,
|
||||
'total' => (int) ($revenue->total_revenue ?? 0),
|
||||
'net' => (int) ($revenue->total_revenue ?? 0) - $potongan,
|
||||
'potongan' => $potongan,
|
||||
'ongkir' => (int) ($revenue->total_shipping ?? 0),
|
||||
];
|
||||
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$purchase = Purchase::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('COALESCE(SUM(total), 0) as total')
|
||||
->first();
|
||||
|
||||
$expenses = Expense::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$employeeAdvance = EmployeeAdvance::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$purchaseTotal = (int) ($purchase->total ?? 0);
|
||||
$expenseTotal = (int) ($expenses->total ?? 0);
|
||||
$advanceTotal = (int) ($employeeAdvance->total ?? 0);
|
||||
|
||||
return [
|
||||
'total' => $purchaseTotal + $expenseTotal + $advanceTotal,
|
||||
'purchase_total' => $purchaseTotal,
|
||||
'expense_total' => $expenseTotal,
|
||||
'advance_total' => $advanceTotal,
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonthlyExpense(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$purchases = Purchase::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate]))
|
||||
->selectRaw("DATE_FORMAT(purchases.created_at, '%Y-%m') as month_key, COALESCE(SUM(total), 0) as total")
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get();
|
||||
|
||||
$expenses = Expense::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate]))
|
||||
->selectRaw("DATE_FORMAT(expenses.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount), 0) as total")
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get();
|
||||
|
||||
$advances = EmployeeAdvance::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate]))
|
||||
->selectRaw("DATE_FORMAT(employee_advances.created_at, '%Y-%m') as month_key, COALESCE(SUM(amount), 0) as total")
|
||||
->groupBy('month_key')
|
||||
->orderBy('month_key')
|
||||
->get();
|
||||
|
||||
$allMonths = collect()
|
||||
->merge($purchases->pluck('month_key'))
|
||||
->merge($expenses->pluck('month_key'))
|
||||
->merge($advances->pluck('month_key'))
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($allMonths->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$start = $startDate ?? Carbon::parse($allMonths->first().'-01');
|
||||
$end = $endDate ?? Carbon::parse($allMonths->last().'-01')->endOfMonth();
|
||||
|
||||
$result = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
while ($current->lte($end)) {
|
||||
$key = $current->format('Y-m');
|
||||
$monthLabel = $current->locale('id')->translatedFormat('M Y');
|
||||
|
||||
$purchaseAmount = (int) ($purchases->firstWhere('month_key', $key)->total ?? 0);
|
||||
$expenseAmount = (int) ($expenses->firstWhere('month_key', $key)->total ?? 0);
|
||||
$advanceAmount = (int) ($advances->firstWhere('month_key', $key)->total ?? 0);
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthLabel,
|
||||
'total' => $purchaseAmount + $expenseAmount + $advanceAmount,
|
||||
'belanja' => $purchaseAmount,
|
||||
'pengeluaran' => $expenseAmount,
|
||||
'kasbon' => $advanceAmount,
|
||||
];
|
||||
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getProfitMetrics(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$orderQuery = Order::query()->completed();
|
||||
if ($startDate && $endDate) {
|
||||
$orderQuery->whereBetween('orders.created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
$revenueData = (clone $orderQuery)
|
||||
->selectRaw('
|
||||
COUNT(*) as total_orders,
|
||||
SUM(total_amount) as total_revenue,
|
||||
SUM(discount) as total_discount,
|
||||
SUM(subtotal) as total_subtotal
|
||||
')
|
||||
->first();
|
||||
|
||||
$totalRevenue = (int) ($revenueData->total_revenue ?? 0);
|
||||
$totalDiscount = (int) ($revenueData->total_discount ?? 0);
|
||||
$totalSubtotal = (int) ($revenueData->total_subtotal ?? 0);
|
||||
$totalOrders = (int) ($revenueData->total_orders ?? 0);
|
||||
|
||||
$marketplaceFees = (clone $orderQuery)
|
||||
->whereNotNull('marketplace_settings_snapshot')
|
||||
->get()
|
||||
->sum(fn ($order) => (int) ($order->marketplace_settings_snapshot['total_fee_amount'] ?? 0));
|
||||
|
||||
$itemsData = OrderItem::query()
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->where('orders.status', OrderStatus::COMPLETED)
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('
|
||||
SUM(order_items.quantity) as total_qty,
|
||||
COUNT(order_items.id) as total_items
|
||||
')
|
||||
->first();
|
||||
|
||||
$totalQty = (int) ($itemsData->total_qty ?? 0);
|
||||
$totalItems = (int) ($itemsData->total_items ?? 0);
|
||||
|
||||
// HPP from cutting_result_prices
|
||||
$hpp = OrderItem::query()
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->leftJoin('cutting_result_prices', 'order_items.product_variant_id', '=', 'cutting_result_prices.product_variant_id')
|
||||
->where('orders.status', OrderStatus::COMPLETED)
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('COALESCE(SUM(order_items.quantity * cutting_result_prices.cost_per_unit), 0) as total_hpp')
|
||||
->value('total_hpp');
|
||||
|
||||
$totalHpp = (int) ($hpp ?? 0);
|
||||
|
||||
// Expenses
|
||||
$purchaseTotal = Purchase::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('purchases.created_at', [$startDate, $endDate]))
|
||||
->sum('total');
|
||||
|
||||
$expenseTotal = Expense::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('expenses.created_at', [$startDate, $endDate]))
|
||||
->sum('amount');
|
||||
|
||||
$advanceTotal = EmployeeAdvance::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('employee_advances.created_at', [$startDate, $endDate]))
|
||||
->sum('amount');
|
||||
|
||||
$totalExpenses = (int) $purchaseTotal + (int) $expenseTotal + (int) $advanceTotal;
|
||||
|
||||
$labaKotor = $totalRevenue - $totalHpp - $totalDiscount - $marketplaceFees;
|
||||
$labaBersih = $labaKotor - $totalExpenses;
|
||||
$profitMargin = $totalRevenue > 0 ? round(($labaBersih / $totalRevenue) * 100, 1) : 0;
|
||||
$aov = $totalOrders > 0 ? (int) round($totalRevenue / $totalOrders) : 0;
|
||||
$itemsPerTransaction = $totalOrders > 0 ? round($totalItems / $totalOrders, 1) : 0;
|
||||
|
||||
return [
|
||||
'total_orders' => $totalOrders,
|
||||
'total_products_sold' => $totalQty,
|
||||
'hpp' => $totalHpp,
|
||||
'laba_kotor' => $labaKotor,
|
||||
'laba_bersih' => $labaBersih,
|
||||
'profit_margin' => $profitMargin,
|
||||
'aov' => $aov,
|
||||
'items_per_transaction' => $itemsPerTransaction,
|
||||
];
|
||||
}
|
||||
|
||||
public function getRawMaterialStock(): array
|
||||
{
|
||||
$prices = RawMaterialPrice::query()
|
||||
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id')
|
||||
->selectRaw('
|
||||
raw_materials.unit,
|
||||
SUM(raw_material_prices.stock) as total_stock,
|
||||
SUM(raw_material_prices.stock * raw_material_prices.price) as total_value
|
||||
')
|
||||
->groupBy('raw_materials.unit')
|
||||
->get()
|
||||
->keyBy('unit');
|
||||
|
||||
$totalStock = (float) $prices->sum('total_stock');
|
||||
$totalValue = (int) $prices->sum('total_value');
|
||||
|
||||
return [
|
||||
'total_stock' => round($totalStock, 2),
|
||||
'total_value' => $totalValue,
|
||||
'by_unit' => [
|
||||
'yard' => round((float) ($prices->get('yard')->total_stock ?? 0), 2),
|
||||
'meter' => round((float) ($prices->get('meter')->total_stock ?? 0), 2),
|
||||
'kilogram' => round((float) ($prices->get('kilogram')->total_stock ?? 0), 2),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getProductStock(): array
|
||||
{
|
||||
$variants = ProductVariant::query()
|
||||
->join('products', 'product_variants.product_id', '=', 'products.id')
|
||||
->selectRaw('
|
||||
SUM(product_variants.stock) as total_stock,
|
||||
SUM(product_variants.reject_stock) as total_reject,
|
||||
COUNT(product_variants.id) as total_variants,
|
||||
COUNT(DISTINCT products.id) as total_products
|
||||
')
|
||||
->first();
|
||||
|
||||
$totalValue = \DB::table('product_prices')
|
||||
->join('product_variants', 'product_prices.variant_id', '=', 'product_variants.id')
|
||||
->selectRaw('SUM(product_variants.stock * product_prices.price) as total')
|
||||
->value('total');
|
||||
|
||||
$totalCategories = \DB::table('product_categories')
|
||||
->distinct('category_id')
|
||||
->count('category_id');
|
||||
|
||||
return [
|
||||
'total_stock' => (int) ($variants->total_stock ?? 0),
|
||||
'total_reject' => (int) ($variants->total_reject ?? 0),
|
||||
'total_value' => (int) ($totalValue ?? 0),
|
||||
'total_products' => (int) ($variants->total_products ?? 0),
|
||||
'total_variants' => (int) ($variants->total_variants ?? 0),
|
||||
'total_categories' => (int) $totalCategories,
|
||||
];
|
||||
}
|
||||
|
||||
public function getBusyHours(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$hourlyData = Order::query()
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->selectRaw('HOUR(orders.created_at) as hour, COUNT(*) as order_count')
|
||||
->groupBy('hour')
|
||||
->orderBy('hour')
|
||||
->get()
|
||||
->keyBy('hour');
|
||||
|
||||
$result = [];
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$result[] = [
|
||||
'hour' => str_pad($h, 2, '0', STR_PAD_LEFT).':00',
|
||||
'orders' => (int) ($hourlyData->get($h)->order_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
return OrderItem::query()
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
|
||||
->join('products', 'product_variants.product_id', '=', 'products.id')
|
||||
->where('orders.status', OrderStatus::COMPLETED)
|
||||
->when($startDate && $endDate, fn ($q) => $q->whereBetween('orders.created_at', [$startDate, $endDate]))
|
||||
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue")
|
||||
->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name')
|
||||
->orderByDesc('total_qty')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->full_name,
|
||||
'total_qty' => (int) $item->total_qty,
|
||||
'total_revenue' => (int) $item->total_revenue,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Models\Expense;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Purchase;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@ -125,64 +124,6 @@ public function getExpenseSummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function getTopSuppliers(): array
|
||||
{
|
||||
return Purchase::query()
|
||||
->whereDate('purchases.created_at', Carbon::today())
|
||||
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
|
||||
->selectRaw('suppliers.name, SUM(purchases.total) as total_amount, COUNT(purchases.id) as purchase_count')
|
||||
->groupBy('suppliers.id', 'suppliers.name')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->name,
|
||||
'total_amount' => (int) $item->total_amount,
|
||||
'purchase_count' => (int) $item->purchase_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getTopCustomers(): array
|
||||
{
|
||||
return Order::query()
|
||||
->completed()
|
||||
->whereDate('orders.created_at', Carbon::today())
|
||||
->join('customers', 'orders.customer_id', '=', 'customers.id')
|
||||
->selectRaw('customers.name, SUM(orders.total_amount) as total_amount, COUNT(orders.id) as order_count')
|
||||
->groupBy('customers.id', 'customers.name')
|
||||
->orderByDesc('total_amount')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->name,
|
||||
'total_amount' => (int) $item->total_amount,
|
||||
'order_count' => (int) $item->order_count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getTopProducts(): array
|
||||
{
|
||||
return OrderItem::query()
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
|
||||
->join('products', 'product_variants.product_id', '=', 'products.id')
|
||||
->where('orders.status', OrderStatus::COMPLETED)
|
||||
->whereDate('orders.created_at', Carbon::today())
|
||||
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, SUM(order_items.quantity) as total_qty, SUM(order_items.subtotal) as total_revenue")
|
||||
->groupBy('order_items.product_variant_id', 'products.name', 'product_variants.name')
|
||||
->orderByDesc('total_qty')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->full_name,
|
||||
'total_qty' => (int) $item->total_qty,
|
||||
'total_revenue' => (int) $item->total_revenue,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getOrderStats(): array
|
||||
{
|
||||
$byChannel = Order::query()
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import LowStockCard from '@/components/card/LowStockCard.vue';
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { Banknote, DollarSign, Package, Percent, ShoppingCart, TrendingDown, TrendingUp, UserCheck } from '@lucide/vue';
|
||||
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import StatCard from '@/components/card/StatCard.vue';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@ -10,56 +12,48 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import type { ChartConfig } from '@/components/ui/chart';
|
||||
import { ChartContainer } from '@/components/ui/chart';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import admin from '@/routes/admin';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Banknote,
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
Calendar,
|
||||
} from '@lucide/vue';
|
||||
|
||||
const props = defineProps<{
|
||||
filters: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
};
|
||||
attendance: {
|
||||
total_employees: number;
|
||||
percentage: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
on_leave: number;
|
||||
};
|
||||
cashOverview: {
|
||||
total_balance: number;
|
||||
total_transactions: number;
|
||||
total_deposit: number;
|
||||
total_withdrawal: number;
|
||||
};
|
||||
rawMaterialStock: {
|
||||
total_stock: number;
|
||||
total_value: number;
|
||||
by_unit: Record<string, number>;
|
||||
by_unit: {
|
||||
yard: number;
|
||||
meter: number;
|
||||
kilogram: number;
|
||||
};
|
||||
};
|
||||
productStock: {
|
||||
total_stock: number;
|
||||
total_reject: number;
|
||||
total_value: number;
|
||||
total_variants: number;
|
||||
total_products: number;
|
||||
total_variants: number;
|
||||
total_categories: number;
|
||||
};
|
||||
lowStockProducts: Array<{
|
||||
name: string;
|
||||
stock: number;
|
||||
}>;
|
||||
lowStockMaterials: Array<{
|
||||
name: string;
|
||||
stock: number;
|
||||
unit: string;
|
||||
}>;
|
||||
purchaseSummary: {
|
||||
total_purchases: number;
|
||||
total_spent: number;
|
||||
total_discount: number;
|
||||
};
|
||||
revenueSummary: {
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
@ -69,38 +63,55 @@ const props = defineProps<{
|
||||
total_orders: number;
|
||||
avg_order: number;
|
||||
};
|
||||
cashAccounts: Array<{
|
||||
name: string;
|
||||
balance: number;
|
||||
monthlyRevenue: Array<{
|
||||
month: string;
|
||||
total: number;
|
||||
net: number;
|
||||
potongan: number;
|
||||
ongkir: number;
|
||||
}>;
|
||||
cashSummary: {
|
||||
total_transactions: number;
|
||||
total_deposit: number;
|
||||
total_withdrawal: number;
|
||||
};
|
||||
monthlyExpenses: {
|
||||
expenseSummary: {
|
||||
total: number;
|
||||
count: number;
|
||||
purchase_total: number;
|
||||
expense_total: number;
|
||||
advance_total: number;
|
||||
};
|
||||
kasbonSummary: {
|
||||
pending: { count: number; total: number };
|
||||
approved: { count: number; total: number };
|
||||
paid: { count: number; total: number };
|
||||
monthlyExpense: Array<{
|
||||
month: string;
|
||||
total: number;
|
||||
total_employees: number;
|
||||
};
|
||||
leaveRequestSummary: {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
attendanceToday: {
|
||||
total_employees: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
on_leave: number;
|
||||
belanja: number;
|
||||
pengeluaran: number;
|
||||
kasbon: number;
|
||||
}>;
|
||||
busyHours: Array<{
|
||||
hour: string;
|
||||
orders: number;
|
||||
}>;
|
||||
profitMetrics: {
|
||||
total_orders: number;
|
||||
total_products_sold: number;
|
||||
hpp: number;
|
||||
laba_kotor: number;
|
||||
laba_bersih: 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;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
const startDate = ref(props.filters.start_date ?? '');
|
||||
@ -108,12 +119,131 @@ const endDate = ref(props.filters.end_date ?? '');
|
||||
|
||||
const hasActiveFilters = computed(() => !!startDate.value || !!endDate.value);
|
||||
|
||||
function formatRupiah(value: number): string {
|
||||
return 'Rp ' + value.toLocaleString('id-ID');
|
||||
}
|
||||
// Revenue Chart
|
||||
type MonthlyRevenueData = { month: string; total: number; net: number; potongan: number; ongkir: number };
|
||||
|
||||
const totalCashBalance = computed(() => {
|
||||
return props.cashAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||
const revenueChartConfig = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
net: {
|
||||
label: 'Bersih',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
potongan: {
|
||||
label: 'Potongan',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
ongkir: {
|
||||
label: 'Ongkir',
|
||||
color: 'var(--chart-4)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const activeRevenueChart = ref<'total' | 'net' | 'potongan' | 'ongkir'>('total');
|
||||
|
||||
const revenueTotals = computed(() => ({
|
||||
total: props.revenueSummary.total_revenue,
|
||||
net: props.revenueSummary.total_revenue - props.revenueSummary.total_potongan,
|
||||
potongan: props.revenueSummary.total_potongan,
|
||||
ongkir: props.revenueSummary.total_shipping,
|
||||
}));
|
||||
|
||||
// Expense Chart
|
||||
type MonthlyExpenseData = { month: string; total: number; belanja: number; pengeluaran: number; kasbon: number };
|
||||
|
||||
const expenseChartConfig = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
belanja: {
|
||||
label: 'Belanja',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
pengeluaran: {
|
||||
label: 'Pengeluaran',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
kasbon: {
|
||||
label: 'Kasbon',
|
||||
color: 'var(--chart-4)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const activeExpenseChart = ref<'total' | 'belanja' | 'pengeluaran' | 'kasbon'>('total');
|
||||
|
||||
const expenseTotals = computed(() => ({
|
||||
total: props.expenseSummary.total,
|
||||
belanja: props.expenseSummary.purchase_total,
|
||||
pengeluaran: props.expenseSummary.expense_total,
|
||||
kasbon: props.expenseSummary.advance_total,
|
||||
}));
|
||||
|
||||
// Busy Hours Chart
|
||||
type BusyHourData = { hour: string; orders: number };
|
||||
|
||||
const busyHoursChartConfig = {
|
||||
orders: {
|
||||
label: 'Pesanan',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const peakHour = computed(() => {
|
||||
if (props.busyHours.length === 0) {
|
||||
return { hour: '-', orders: 0 };
|
||||
}
|
||||
|
||||
return props.busyHours.reduce((max, item) => item.orders > max.orders ? item : max, props.busyHours[0]);
|
||||
});
|
||||
|
||||
// Top 5 Chart configs
|
||||
type SupplierData = { name: string; amount: number };
|
||||
type CustomerData = { name: string; amount: number };
|
||||
type ProductData = { name: string; qty: number };
|
||||
|
||||
const supplierChartConfig = {
|
||||
amount: {
|
||||
label: 'Total Pembelian',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const customerChartConfig = {
|
||||
amount: {
|
||||
label: 'Total Pesanan',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const productChartConfig = {
|
||||
qty: {
|
||||
label: 'Jumlah Terjual',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const supplierBarData = computed<SupplierData[]>(() => {
|
||||
return props.topSuppliers.map((s) => ({
|
||||
name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name,
|
||||
amount: s.total_amount,
|
||||
}));
|
||||
});
|
||||
|
||||
const customerBarData = computed<CustomerData[]>(() => {
|
||||
return props.topCustomers.map((c) => ({
|
||||
name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name,
|
||||
amount: c.total_amount,
|
||||
}));
|
||||
});
|
||||
|
||||
const productBarData = computed<ProductData[]>(() => {
|
||||
return props.topProducts.map((p) => ({
|
||||
name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name,
|
||||
qty: p.total_qty,
|
||||
}));
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
@ -142,301 +272,353 @@ watch([startDate, endDate], () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Analisa" />
|
||||
|
||||
<AdminLayout>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Header & Filter -->
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-lg border bg-card p-6 shadow-xs md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
class="flex flex-col gap-4 rounded-lg border bg-card p-6 shadow-xs md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Analisa</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Halaman analisa data, performa toko, dan inventori.
|
||||
Halaman analisa data dan performa toko.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Date Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>Mulai:</span
|
||||
>
|
||||
<DatePicker
|
||||
v-model="startDate"
|
||||
class="w-[170px]"
|
||||
placeholder="Pilih tanggal"
|
||||
/>
|
||||
<span class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Mulai:</span>
|
||||
<DatePicker v-model="startDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wider text-muted-foreground uppercase"
|
||||
>Sampai:</span
|
||||
>
|
||||
<DatePicker
|
||||
v-model="endDate"
|
||||
class="w-[170px]"
|
||||
placeholder="Pilih tanggal"
|
||||
/>
|
||||
class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Sampai:</span>
|
||||
<DatePicker v-model="endDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
class="h-9 px-3"
|
||||
>
|
||||
<Button v-if="hasActiveFilters" variant="ghost" size="sm" @click="clearFilters" class="h-9 px-3">
|
||||
Reset Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: KPI Statistic Cards -->
|
||||
<!-- Kehadiran & Kas Toko -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 1. Total Pendapatan -->
|
||||
<StatCard
|
||||
title="Total Pendapatan"
|
||||
:icon="TrendingUp"
|
||||
main-label="Total"
|
||||
:main-value="formatRupiah(revenueSummary.total_revenue)"
|
||||
:sub-label="
|
||||
revenueSummary.total_orders +
|
||||
' transaksi selesai' +
|
||||
(hasActiveFilters ? ' (filter)' : ' (keseluruhan)')
|
||||
"
|
||||
:items="[
|
||||
{
|
||||
label: 'Bersih',
|
||||
value: formatRupiah(
|
||||
revenueSummary.total_revenue -
|
||||
revenueSummary.total_potongan,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Potongan',
|
||||
value: formatRupiah(revenueSummary.total_potongan),
|
||||
},
|
||||
{
|
||||
label: 'Diskon',
|
||||
value: formatRupiah(revenueSummary.total_discount),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 2. Total Pengeluaran -->
|
||||
<StatCard
|
||||
title="Total Pengeluaran"
|
||||
:icon="TrendingDown"
|
||||
main-label="Total"
|
||||
:main-value="
|
||||
formatRupiah(
|
||||
purchaseSummary.total_spent +
|
||||
monthlyExpenses.total +
|
||||
kasbonSummary.total,
|
||||
)
|
||||
"
|
||||
:items="[
|
||||
{
|
||||
label: 'Belanja',
|
||||
value: formatRupiah(purchaseSummary.total_spent),
|
||||
},
|
||||
{
|
||||
label: 'Pengeluaran',
|
||||
value: formatRupiah(monthlyExpenses.total),
|
||||
},
|
||||
{
|
||||
label: 'Kasbon',
|
||||
value: formatRupiah(kasbonSummary.total),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 3. Kas Toko -->
|
||||
<StatCard
|
||||
title="Kas Toko"
|
||||
:icon="Banknote"
|
||||
main-label="Total Saldo"
|
||||
:main-value="formatRupiah(totalCashBalance)"
|
||||
:sub-label="
|
||||
cashSummary.total_transactions +
|
||||
' transaksi' +
|
||||
(hasActiveFilters ? ' (filter)' : ' (keseluruhan)')
|
||||
"
|
||||
:items="[
|
||||
{
|
||||
label: 'Deposit',
|
||||
value: formatRupiah(cashSummary.total_deposit),
|
||||
},
|
||||
{
|
||||
label: 'Withdrawal',
|
||||
value: formatRupiah(cashSummary.total_withdrawal),
|
||||
},
|
||||
]"
|
||||
:cols="2"
|
||||
/>
|
||||
|
||||
<!-- 4. Kehadiran -->
|
||||
<StatCard
|
||||
title="Kehadiran"
|
||||
:icon="UserCheck"
|
||||
main-label="Slot Kehadiran"
|
||||
:main-value="attendanceToday.total_employees"
|
||||
:sub-label="
|
||||
(attendanceToday.total_employees > 0
|
||||
? Math.round(
|
||||
(attendanceToday.present /
|
||||
attendanceToday.total_employees) *
|
||||
100,
|
||||
)
|
||||
: 0) + '% hadir'
|
||||
"
|
||||
:items="[
|
||||
<StatCard title="Kehadiran" :icon="UserCheck" main-label="Total Karyawan"
|
||||
:main-value="attendance.total_employees" :sub-label="attendance.percentage + '% hadir'" :items="[
|
||||
{
|
||||
label: 'Hadir',
|
||||
value: attendanceToday.present,
|
||||
value: attendance.present,
|
||||
},
|
||||
{
|
||||
label: 'Tidak Hadir',
|
||||
value: attendanceToday.absent,
|
||||
value: attendance.absent,
|
||||
},
|
||||
{
|
||||
label: 'Cuti',
|
||||
value: attendanceToday.on_leave ?? 0,
|
||||
value: attendance.on_leave,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
]" />
|
||||
|
||||
<!-- 5. Bahan Baku -->
|
||||
<StatCard
|
||||
title="Bahan Baku"
|
||||
:icon="Package"
|
||||
main-label="Total Stok"
|
||||
:main-value="
|
||||
rawMaterialStock.total_stock.toLocaleString('id-ID')
|
||||
"
|
||||
:sub-label="formatRupiah(rawMaterialStock.total_value)"
|
||||
:items="[
|
||||
<StatCard title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
|
||||
:main-value="'Rp' + formatRupiah(cashOverview.total_balance)" :sub-label="cashOverview.total_transactions + ' transaksi'
|
||||
" :items="[
|
||||
{
|
||||
label: 'Deposit',
|
||||
value: 'Rp' + formatRupiah(cashOverview.total_deposit),
|
||||
},
|
||||
{
|
||||
label: 'Withdrawal',
|
||||
value: 'Rp' + formatRupiah(cashOverview.total_withdrawal),
|
||||
},
|
||||
]" :cols="2" />
|
||||
|
||||
<StatCard title="Bahan Baku" :icon="Package" main-label="Total Stok"
|
||||
:main-value="rawMaterialStock.total_stock.toLocaleString('id-ID')"
|
||||
:sub-label="'Rp' + formatRupiah(rawMaterialStock.total_value)" :items="[
|
||||
{
|
||||
label: 'Yard',
|
||||
value: (
|
||||
rawMaterialStock.by_unit?.yard ?? 0
|
||||
).toLocaleString('id-ID'),
|
||||
value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0',
|
||||
},
|
||||
{
|
||||
label: 'Meter',
|
||||
value: (
|
||||
rawMaterialStock.by_unit?.meter ?? 0
|
||||
).toLocaleString('id-ID'),
|
||||
value: rawMaterialStock.by_unit?.meter?.toLocaleString('id-ID') ?? '0',
|
||||
},
|
||||
{
|
||||
label: 'Kg',
|
||||
value: (
|
||||
rawMaterialStock.by_unit?.kilogram ?? 0
|
||||
).toLocaleString('id-ID'),
|
||||
value: rawMaterialStock.by_unit?.kilogram?.toLocaleString('id-ID') ?? '0',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
]" />
|
||||
|
||||
<!-- 6. Stok Produk -->
|
||||
<StatCard
|
||||
title="Stok Produk"
|
||||
:icon="ShoppingCart"
|
||||
main-label="Total Stok"
|
||||
:main-value="
|
||||
productStock.total_stock.toLocaleString('id-ID')
|
||||
"
|
||||
:sub-label="
|
||||
formatRupiah(productStock.total_value) +
|
||||
(productStock.total_reject > 0
|
||||
? ` (${productStock.total_reject} reject)`
|
||||
: '')
|
||||
"
|
||||
<StatCard title="Stok Produk" :icon="ShoppingCart" main-label="Total Stok"
|
||||
:main-value="productStock.total_stock.toLocaleString('id-ID')"
|
||||
:sub-label="'Rp' + formatRupiah(productStock.total_value) + (productStock.total_reject > 0 ? ' (' + productStock.total_reject + ' reject)' : '')"
|
||||
:items="[
|
||||
{
|
||||
label: 'Produk',
|
||||
value: productStock.total_products.toLocaleString(
|
||||
'id-ID',
|
||||
),
|
||||
value: productStock.total_products.toLocaleString('id-ID'),
|
||||
},
|
||||
{
|
||||
label: 'Varian',
|
||||
value: productStock.total_variants.toLocaleString(
|
||||
'id-ID',
|
||||
),
|
||||
value: productStock.total_variants.toLocaleString('id-ID'),
|
||||
},
|
||||
{
|
||||
label: 'Kategori',
|
||||
value: productStock.total_categories.toLocaleString(
|
||||
'id-ID',
|
||||
),
|
||||
value: productStock.total_categories.toLocaleString('id-ID'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 7. Kasbon Karyawan -->
|
||||
<StatCard
|
||||
title="Kasbon Karyawan"
|
||||
:icon="CreditCard"
|
||||
main-label="Total Kasbon"
|
||||
:main-value="formatRupiah(kasbonSummary.total)"
|
||||
:sub-label="kasbonSummary.total_employees + ' karyawan'"
|
||||
:items="[
|
||||
{
|
||||
label: 'Menunggu',
|
||||
value: formatRupiah(kasbonSummary.pending.total),
|
||||
},
|
||||
{
|
||||
label: 'Disetujui',
|
||||
value: formatRupiah(kasbonSummary.approved.total),
|
||||
},
|
||||
{
|
||||
label: 'Dibayar',
|
||||
value: formatRupiah(kasbonSummary.paid.total),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- 8. Pengajuan Cuti -->
|
||||
<StatCard
|
||||
title="Pengajuan Cuti"
|
||||
:icon="FileText"
|
||||
main-label="Total Pengajuan"
|
||||
:main-value="leaveRequestSummary.total"
|
||||
:items="[
|
||||
{
|
||||
label: 'Menunggu',
|
||||
value: leaveRequestSummary.pending,
|
||||
},
|
||||
{
|
||||
label: 'Disetujui',
|
||||
value: leaveRequestSummary.approved,
|
||||
},
|
||||
{
|
||||
label: 'Ditolak',
|
||||
value: leaveRequestSummary.rejected,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Low Stock Alerts -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Stok Produk Menipis -->
|
||||
<LowStockCard
|
||||
title="Stok Produk Menipis"
|
||||
:icon="AlertTriangle"
|
||||
:items="lowStockProducts"
|
||||
empty-text="Semua stok produk aman"
|
||||
/>
|
||||
<!-- Revenue Chart -->
|
||||
<Card class="py-4 sm:py-0">
|
||||
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Pendapatan</CardTitle>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button v-for="chart in ['total', 'net', 'potongan', 'ongkir'] as const" :key="chart"
|
||||
:data-active="activeRevenueChart === chart"
|
||||
class="data-[active=true]:bg-muted/50 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"
|
||||
@click="activeRevenueChart = chart">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ revenueChartConfig[chart].label }}
|
||||
</span>
|
||||
<span class="text-sm">
|
||||
Rp{{ formatRupiah(revenueTotals[chart]) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 sm:p-6">
|
||||
<ChartContainer v-if="monthlyRevenue.length > 0" :config="revenueChartConfig"
|
||||
class="aspect-auto h-[300px] w-full">
|
||||
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i"
|
||||
:y="(d: MonthlyRevenueData) => d[activeRevenueChart]"
|
||||
:color="revenueChartConfig[activeRevenueChart].color" :bar-padding="0.1"
|
||||
:rounded-corners="4" />
|
||||
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => monthlyRevenue[d]?.month ?? ''"
|
||||
:tick-values="monthlyRevenue.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data pendapatan
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Stok Bahan Baku Menipis -->
|
||||
<LowStockCard
|
||||
title="Stok Bahan Baku Menipis"
|
||||
:icon="AlertTriangle"
|
||||
:items="lowStockMaterials"
|
||||
empty-text="Semua stok bahan baku aman"
|
||||
/>
|
||||
<!-- Expense Chart -->
|
||||
<Card class="py-4 sm:py-0">
|
||||
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Pengeluaran</CardTitle>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button v-for="chart in ['total', 'belanja', 'pengeluaran', 'kasbon'] as const" :key="chart"
|
||||
:data-active="activeExpenseChart === chart"
|
||||
class="data-[active=true]:bg-muted/50 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"
|
||||
@click="activeExpenseChart = chart">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ expenseChartConfig[chart].label }}
|
||||
</span>
|
||||
<span class="text-sm">
|
||||
Rp{{ formatRupiah(expenseTotals[chart]) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 sm:p-6">
|
||||
<ChartContainer v-if="monthlyExpense.length > 0" :config="expenseChartConfig"
|
||||
class="aspect-auto h-[300px] w-full">
|
||||
<VisXYContainer :data="monthlyExpense" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: MonthlyExpenseData, i: number) => i"
|
||||
:y="(d: MonthlyExpenseData) => d[activeExpenseChart]"
|
||||
:color="expenseChartConfig[activeExpenseChart].color" :bar-padding="0.1"
|
||||
:rounded-corners="4" />
|
||||
<VisAxis type="x" :x="(_d: MonthlyExpenseData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => monthlyExpense[d]?.month ?? ''"
|
||||
:tick-values="monthlyExpense.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data pengeluaran
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Busy Hours Chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Jam Sibuk Toko</CardTitle>
|
||||
<CardDescription>Distribusi pesanan berdasarkan jam dalam sehari</CardDescription>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Jam Tersibuk</p>
|
||||
<p class="text-2xl font-bold text-primary">{{ peakHour.hour }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ peakHour.orders }} pesanan</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="busyHours.length > 0" :config="busyHoursChartConfig"
|
||||
class="aspect-auto h-[250px] w-full">
|
||||
<VisXYContainer :data="busyHours" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: BusyHourData, i: number) => i" :y="(d: BusyHourData) => d.orders"
|
||||
:color="busyHoursChartConfig.orders.color" :bar-padding="0.1" :rounded-corners="4" />
|
||||
<VisAxis type="x" :x="(_d: BusyHourData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => busyHours[d]?.hour ?? ''"
|
||||
:tick-values="busyHours.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => Math.round(d).toString()" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data pesanan
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Profit Metrics Cards -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title="Total Order" :icon="ShoppingCart" main-label="Pesanan Selesai"
|
||||
:main-value="profitMetrics.total_orders.toLocaleString('id-ID')" :sub-label="'AOV: Rp' + formatRupiah(profitMetrics.aov)" :items="[
|
||||
{
|
||||
label: 'Produk Terjual',
|
||||
value: profitMetrics.total_products_sold.toLocaleString('id-ID'),
|
||||
},
|
||||
{
|
||||
label: 'Item/Transaksi',
|
||||
value: profitMetrics.items_per_transaction,
|
||||
},
|
||||
]" />
|
||||
|
||||
<StatCard title="HPP" :icon="TrendingDown" main-label="Harga Pokok"
|
||||
:main-value="'Rp' + formatRupiah(profitMetrics.hpp)" sub-label="Total biaya produksi" :items="[
|
||||
{
|
||||
label: 'Laba Kotor',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.laba_kotor),
|
||||
},
|
||||
{
|
||||
label: 'Laba Bersih',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih),
|
||||
},
|
||||
]" />
|
||||
|
||||
<StatCard title="Laba Kotor" :icon="TrendingUp" main-label="Gross Profit"
|
||||
:main-value="'Rp' + formatRupiah(profitMetrics.laba_kotor)"
|
||||
:sub-label="profitMetrics.laba_kotor >= 0 ? 'Positif' : 'Negatif'" :items="[
|
||||
{
|
||||
label: 'Revenue',
|
||||
value: 'Rp' + formatRupiah(revenueSummary.total_revenue),
|
||||
},
|
||||
{
|
||||
label: 'HPP',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
||||
},
|
||||
]" />
|
||||
|
||||
<StatCard title="Profit Margin" :icon="Percent" main-label="Margin"
|
||||
:main-value="profitMetrics.profit_margin + '%'"
|
||||
:sub-label="'Laba Bersih: Rp' + formatRupiah(profitMetrics.laba_bersih)" :items="[
|
||||
{
|
||||
label: 'Laba Kotor',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.laba_kotor),
|
||||
},
|
||||
{
|
||||
label: 'Total Pengeluaran',
|
||||
value: 'Rp' + formatRupiah(expenseSummary.total),
|
||||
},
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<!-- Top 5 Charts -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
||||
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: SupplierData, i: number) => i"
|
||||
:y="(d: SupplierData) => d.amount" :color="supplierChartConfig.amount.color"
|
||||
:rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => supplierBarData[d]?.name ?? ''"
|
||||
:tick-values="supplierBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data supplier
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
||||
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="customerBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: CustomerData, i: number) => i"
|
||||
:y="(d: CustomerData) => d.amount" :color="customerChartConfig.amount.color"
|
||||
:rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => customerBarData[d]?.name ?? ''"
|
||||
:tick-values="customerBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data pelanggan
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Produk</CardTitle>
|
||||
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="productBarData.length > 0" :config="productChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="productBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: ProductData, i: number) => i" :y="(d: ProductData) => d.qty"
|
||||
:color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => productBarData[d]?.name ?? ''"
|
||||
:tick-values="productBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data produk
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
@ -55,22 +55,6 @@ interface DashboardProps {
|
||||
advance_total: 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;
|
||||
}>;
|
||||
|
||||
orderStats: {
|
||||
by_channel: Array<{
|
||||
channel: string;
|
||||
@ -149,27 +133,6 @@ const greeting = computed(() => {
|
||||
});
|
||||
|
||||
// Chart configs
|
||||
const supplierChartConfig = {
|
||||
amount: {
|
||||
label: 'Total Pembelian',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const customerChartConfig = {
|
||||
amount: {
|
||||
label: 'Total Pesanan',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const productChartConfig = {
|
||||
qty: {
|
||||
label: 'Jumlah Terjual',
|
||||
color: 'var(--chart-3)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const channelColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];
|
||||
const channelChartConfig = computed(() => {
|
||||
const config: ChartConfig = {};
|
||||
@ -217,31 +180,6 @@ const marketingChartConfig = computed(() => {
|
||||
});
|
||||
return config;
|
||||
});
|
||||
|
||||
type SupplierData = { name: string; amount: number };
|
||||
type CustomerData = { name: string; amount: number };
|
||||
type ProductData = { name: string; qty: number };
|
||||
|
||||
const supplierBarData = computed<SupplierData[]>(() => {
|
||||
return props.topSuppliers.map((s) => ({
|
||||
name: s.name.length > 15 ? s.name.substring(0, 15) + '...' : s.name,
|
||||
amount: s.total_amount,
|
||||
}));
|
||||
});
|
||||
|
||||
const customerBarData = computed<CustomerData[]>(() => {
|
||||
return props.topCustomers.map((c) => ({
|
||||
name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name,
|
||||
amount: c.total_amount,
|
||||
}));
|
||||
});
|
||||
|
||||
const productBarData = computed<ProductData[]>(() => {
|
||||
return props.topProducts.map((p) => ({
|
||||
name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name,
|
||||
qty: p.total_qty,
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -348,84 +286,6 @@ const productBarData = computed<ProductData[]>(() => {
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
||||
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: SupplierData, i: number) => i"
|
||||
:y="(d: SupplierData) => d.amount" :color="supplierChartConfig.amount.color"
|
||||
:rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => supplierBarData[d]?.name ?? ''"
|
||||
:tick-values="supplierBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data supplier
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
||||
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="customerBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: CustomerData, i: number) => i"
|
||||
:y="(d: CustomerData) => d.amount" :color="customerChartConfig.amount.color"
|
||||
:rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => customerBarData[d]?.name ?? ''"
|
||||
:tick-values="customerBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false"
|
||||
:tick-format="(d: number) => 'Rp' + formatRupiah(d)" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data pelanggan
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Produk</CardTitle>
|
||||
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer v-if="productBarData.length > 0" :config="productChartConfig"
|
||||
class="min-h-[250px] w-full">
|
||||
<VisXYContainer :data="productBarData" :y-domain="[0, undefined]">
|
||||
<VisGroupedBar :x="(_d: ProductData, i: number) => i" :y="(d: ProductData) => d.qty"
|
||||
:color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" />
|
||||
<VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false"
|
||||
:domain-line="false" :grid-line="false"
|
||||
:tick-format="(d: number) => productBarData[d]?.name ?? ''"
|
||||
:tick-values="productBarData.map((_, i) => i)" />
|
||||
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
||||
Belum ada data produk
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
103
resources/js/pages/admin/TestChart.vue
Normal file
103
resources/js/pages/admin/TestChart.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
import { TrendingUp } from "@lucide/vue"
|
||||
|
||||
import { CurveType } from "@unovis/ts"
|
||||
import { VisAxis, VisLine, VisXYContainer } from "@unovis/vue"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartCrosshair,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
componentToString,
|
||||
} from "@/components/ui/chart"
|
||||
|
||||
const description = "A line chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: new Date("2024-01-01"), desktop: 186 },
|
||||
{ date: new Date("2024-02-01"), desktop: 305 },
|
||||
{ date: new Date("2024-03-01"), desktop: 237 },
|
||||
{ date: new Date("2024-04-01"), desktop: 73 },
|
||||
{ date: new Date("2024-05-01"), desktop: 209 },
|
||||
{ date: new Date("2024-06-01"), desktop: 214 },
|
||||
]
|
||||
|
||||
type Data = typeof chartData[number]
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Line Chart - Linear</CardTitle>
|
||||
<CardDescription>January - June 2024</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer :config="chartConfig">
|
||||
<VisXYContainer
|
||||
:data="chartData"
|
||||
:margin="{ left: -24 }"
|
||||
:y-domain="[0, undefined]"
|
||||
>
|
||||
<VisLine
|
||||
:x="(d: Data) => d.date"
|
||||
:y="(d: Data) => d.desktop"
|
||||
:color="chartConfig.desktop.color"
|
||||
:curve-type="CurveType.Linear"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(d: Data) => d.date"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="false"
|
||||
:num-ticks="6"
|
||||
:tick-format="(d: number) => {
|
||||
const date = new Date(d)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
})
|
||||
}"
|
||||
:tick-values="chartData.map(d => d.date)"
|
||||
/>
|
||||
<VisAxis
|
||||
type="y"
|
||||
:num-ticks="3"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
/>
|
||||
<ChartTooltip />
|
||||
<ChartCrosshair
|
||||
:template="componentToString(chartConfig, ChartTooltipContent, { hideLabel: true })"
|
||||
:color="chartConfig.desktop.color"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter class="flex-col items-start gap-2 text-sm">
|
||||
<div class="flex gap-2 font-medium leading-none">
|
||||
Trending up by 5.2% this month <TrendingUp class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="leading-none text-muted-foreground">
|
||||
Showing total visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
Loading…
Reference in New Issue
Block a user