feat: enhance Dashboard with new statistics and visualizations, including low stock alerts, monthly cash flow, and employee attendance summaries
This commit is contained in:
parent
7a9bb00711
commit
45f28e69ef
@ -3,28 +3,29 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\LeaveRequestStatus;
|
||||
use App\Enums\EmploymentStatus;
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\Customer;
|
||||
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\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
@ -41,16 +42,26 @@ public function index(): Response
|
||||
return Inertia::render('admin/Dashboard', [
|
||||
'rawMaterialStock' => $this->getRawMaterialStock(),
|
||||
'productStock' => $this->getProductStock(),
|
||||
'lowStockProducts' => $this->getLowStockProducts(),
|
||||
'lowStockMaterials' => $this->getLowStockMaterials(),
|
||||
'topSuppliers' => $this->getTopSuppliers(),
|
||||
'topCustomers' => $this->getTopCustomers(),
|
||||
'topProducts' => $this->getTopProducts(),
|
||||
'purchaseSummary' => $this->getPurchaseSummary(),
|
||||
'cuttingSummary' => $this->getCuttingSummary(),
|
||||
'cuttingByStatus' => $this->getCuttingByStatus(),
|
||||
'orderStats' => $this->getOrderStats(),
|
||||
'revenueSummary' => $this->getRevenueSummary(),
|
||||
'cashAccounts' => $this->getCashAccounts(),
|
||||
'monthlyCashFlow' => $this->getMonthlyCashFlow(),
|
||||
'monthlyExpenses' => $this->getMonthlyExpenses($startOfMonth, $endOfMonth),
|
||||
'kasbonSummary' => $this->getKasbonSummary(),
|
||||
'payrollSummary' => $this->getPayrollSummary($startOfMonth, $endOfMonth),
|
||||
'leaveRequestSummary' => $this->getLeaveRequestSummary($startOfMonth, $endOfMonth),
|
||||
'employeeSummary' => $this->getEmployeeSummary(),
|
||||
'attendanceToday' => $this->getAttendanceToday(),
|
||||
'monthlyRevenueTrend' => $this->getMonthlyRevenueTrend(),
|
||||
'monthlyPurchaseTrend' => $this->getMonthlyPurchaseTrend(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -87,6 +98,41 @@ private function getProductStock(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function getLowStockProducts(): array
|
||||
{
|
||||
return ProductVariant::query()
|
||||
->where('stock', '<=', ProductVariant::minStock())
|
||||
->where('stock', '>', 0)
|
||||
->join('products', 'product_variants.product_id', '=', 'products.id')
|
||||
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock")
|
||||
->orderBy('stock')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->full_name,
|
||||
'stock' => (int) $item->stock,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function getLowStockMaterials(): array
|
||||
{
|
||||
return RawMaterialPrice::query()
|
||||
->where('stock', '<=', 5)
|
||||
->where('stock', '>', 0)
|
||||
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id')
|
||||
->selectRaw("CONCAT(raw_materials.name, ' - ', raw_material_prices.variant) as full_name, raw_material_prices.stock, raw_materials.unit")
|
||||
->orderBy('stock')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'name' => $item->full_name,
|
||||
'stock' => (float) $item->stock,
|
||||
'unit' => $item->unit,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function getTopSuppliers(): array
|
||||
{
|
||||
return Purchase::query()
|
||||
@ -122,6 +168,26 @@ private function getTopCustomers(): array
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private 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)
|
||||
->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();
|
||||
}
|
||||
|
||||
private function getPurchaseSummary(): array
|
||||
{
|
||||
$data = Purchase::query()
|
||||
@ -153,6 +219,20 @@ private function getCuttingSummary(): array
|
||||
];
|
||||
}
|
||||
|
||||
private function getCuttingByStatus(): array
|
||||
{
|
||||
return Cutting::query()
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'status' => $item->status instanceof CuttingStatus ? $item->status->value : $item->status,
|
||||
'label' => $item->status instanceof CuttingStatus ? $item->status->label() : CuttingStatus::from($item->status)->label(),
|
||||
'count' => (int) $item->count,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function getOrderStats(): array
|
||||
{
|
||||
$byChannel = Order::query()
|
||||
@ -214,7 +294,7 @@ private function getRevenueSummary(): array
|
||||
{
|
||||
$data = Order::query()
|
||||
->where('status', OrderStatus::COMPLETED)
|
||||
->selectRaw('SUM(total_amount) as total_revenue, SUM(discount) as total_discount, SUM(shipping_cost) as total_shipping, COUNT(*) as total_orders')
|
||||
->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();
|
||||
|
||||
return [
|
||||
@ -222,6 +302,7 @@ private function getRevenueSummary(): array
|
||||
'total_discount' => (int) ($data->total_discount ?? 0),
|
||||
'total_shipping' => (int) ($data->total_shipping ?? 0),
|
||||
'total_orders' => (int) ($data->total_orders ?? 0),
|
||||
'avg_order' => (int) ($data->avg_order ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@ -237,6 +318,53 @@ private function getCashAccounts(): array
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function getMonthlyCashFlow(): array
|
||||
{
|
||||
$months = collect();
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$date = Carbon::now()->subMonths($i);
|
||||
$months->push([
|
||||
'year' => $date->year,
|
||||
'month' => $date->month,
|
||||
'label' => $date->translatedFormat('M Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $months->map(function ($month) {
|
||||
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
|
||||
$end = $start->copy()->endOfMonth();
|
||||
|
||||
$data = CashTransaction::query()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw("
|
||||
COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE 0 END), 0) as deposits,
|
||||
COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as withdrawals
|
||||
")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'label' => $month['label'],
|
||||
'deposits' => (int) $data->deposits,
|
||||
'withdrawals' => (int) $data->withdrawals,
|
||||
];
|
||||
});
|
||||
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
private function getMonthlyExpenses(Carbon $startOfMonth, Carbon $endOfMonth): array
|
||||
{
|
||||
$data = Expense::query()
|
||||
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total, COUNT(*) as count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total' => (int) ($data->total ?? 0),
|
||||
'count' => (int) ($data->count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getKasbonSummary(): array
|
||||
{
|
||||
$pending = EmployeeAdvance::query()
|
||||
@ -336,4 +464,105 @@ private function getLeaveRequestSummary(Carbon $startOfMonth, Carbon $endOfMonth
|
||||
'rejected' => (int) ($data->rejected_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getEmployeeSummary(): array
|
||||
{
|
||||
$total = Employee::query()->count();
|
||||
|
||||
$byStatus = Employee::query()
|
||||
->selectRaw('employment_status, COUNT(*) as count')
|
||||
->groupBy('employment_status')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'status' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->value : $item->employment_status,
|
||||
'label' => $item->employment_status instanceof EmploymentStatus ? $item->employment_status->label() : EmploymentStatus::from($item->employment_status)->label(),
|
||||
'count' => (int) $item->count,
|
||||
]);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'by_status' => $byStatus->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getAttendanceToday(): array
|
||||
{
|
||||
$today = Carbon::today();
|
||||
|
||||
$totalEmployees = Employee::query()->count();
|
||||
|
||||
$present = Attendance::query()
|
||||
->where('attendance_date', $today)
|
||||
->distinct('employee_id')
|
||||
->count('employee_id');
|
||||
|
||||
return [
|
||||
'total_employees' => $totalEmployees,
|
||||
'present' => $present,
|
||||
'absent' => max(0, $totalEmployees - $present),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMonthlyRevenueTrend(): array
|
||||
{
|
||||
$months = collect();
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$date = Carbon::now()->subMonths($i);
|
||||
$months->push([
|
||||
'year' => $date->year,
|
||||
'month' => $date->month,
|
||||
'label' => $date->translatedFormat('M Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $months->map(function ($month) {
|
||||
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
|
||||
$end = $start->copy()->endOfMonth();
|
||||
|
||||
$data = Order::query()
|
||||
->where('status', OrderStatus::COMPLETED)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('COALESCE(SUM(total_amount), 0) as total, COUNT(*) as count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'label' => $month['label'],
|
||||
'total' => (int) $data->total,
|
||||
'count' => (int) $data->count,
|
||||
];
|
||||
});
|
||||
|
||||
return $result->toArray();
|
||||
}
|
||||
|
||||
private function getMonthlyPurchaseTrend(): array
|
||||
{
|
||||
$months = collect();
|
||||
for ($i = 5; $i >= 0; $i--) {
|
||||
$date = Carbon::now()->subMonths($i);
|
||||
$months->push([
|
||||
'year' => $date->year,
|
||||
'month' => $date->month,
|
||||
'label' => $date->translatedFormat('M Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $months->map(function ($month) {
|
||||
$start = Carbon::create($month['year'], $month['month'], 1)->startOfMonth();
|
||||
$end = $start->copy()->endOfMonth();
|
||||
|
||||
$data = Purchase::query()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('COALESCE(SUM(total), 0) as total, COUNT(*) as count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'label' => $month['label'],
|
||||
'total' => (int) $data->total,
|
||||
'count' => (int) $data->count,
|
||||
];
|
||||
});
|
||||
|
||||
return $result->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Banknote,
|
||||
Clock,
|
||||
CreditCard,
|
||||
@ -13,13 +14,15 @@ import {
|
||||
Sunrise,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Users,
|
||||
} from '@lucide/vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ChartContainer, ChartLegendContent } from '@/components/ui/chart';
|
||||
import { VisAxis, VisGroupedBar, VisXYContainer, VisDonut } from '@unovis/vue';
|
||||
import { ChartContainer } from '@/components/ui/chart';
|
||||
import { VisAxis, VisGroupedBar, VisXYContainer, VisDonut, VisStackedBar } from '@unovis/vue';
|
||||
import type { ChartConfig } from '@/components/ui/chart';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
|
||||
@ -33,6 +36,15 @@ interface DashboardProps {
|
||||
total_reject: number;
|
||||
total_value: number;
|
||||
};
|
||||
lowStockProducts: Array<{
|
||||
name: string;
|
||||
stock: number;
|
||||
}>;
|
||||
lowStockMaterials: Array<{
|
||||
name: string;
|
||||
stock: number;
|
||||
unit: string;
|
||||
}>;
|
||||
topSuppliers: Array<{
|
||||
name: string;
|
||||
total_amount: number;
|
||||
@ -43,6 +55,11 @@ interface DashboardProps {
|
||||
total_amount: number;
|
||||
order_count: number;
|
||||
}>;
|
||||
topProducts: Array<{
|
||||
name: string;
|
||||
total_qty: number;
|
||||
total_revenue: number;
|
||||
}>;
|
||||
purchaseSummary: {
|
||||
total_purchases: number;
|
||||
total_spent: number;
|
||||
@ -54,6 +71,11 @@ interface DashboardProps {
|
||||
total_warehouse: number;
|
||||
total_reject: number;
|
||||
};
|
||||
cuttingByStatus: Array<{
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
orderStats: {
|
||||
by_channel: Array<{
|
||||
channel: string;
|
||||
@ -83,11 +105,21 @@ interface DashboardProps {
|
||||
total_discount: number;
|
||||
total_shipping: number;
|
||||
total_orders: number;
|
||||
avg_order: number;
|
||||
};
|
||||
cashAccounts: Array<{
|
||||
name: string;
|
||||
balance: number;
|
||||
}>;
|
||||
monthlyCashFlow: Array<{
|
||||
label: string;
|
||||
deposits: number;
|
||||
withdrawals: number;
|
||||
}>;
|
||||
monthlyExpenses: {
|
||||
total: number;
|
||||
count: number;
|
||||
};
|
||||
kasbonSummary: {
|
||||
pending: { count: number; total: number };
|
||||
approved: { count: number; total: number };
|
||||
@ -110,6 +142,29 @@ interface DashboardProps {
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
employeeSummary: {
|
||||
total: number;
|
||||
by_status: Array<{
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
attendanceToday: {
|
||||
total_employees: number;
|
||||
present: number;
|
||||
absent: number;
|
||||
};
|
||||
monthlyRevenueTrend: Array<{
|
||||
label: string;
|
||||
total: number;
|
||||
count: number;
|
||||
}>;
|
||||
monthlyPurchaseTrend: Array<{
|
||||
label: string;
|
||||
total: number;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const props = defineProps<DashboardProps>();
|
||||
@ -182,6 +237,13 @@ const customerChartConfig = {
|
||||
},
|
||||
} 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)'];
|
||||
const channelChartConfig = computed(() => {
|
||||
const config: ChartConfig = {};
|
||||
@ -230,16 +292,33 @@ const marketingChartConfig = computed(() => {
|
||||
return config;
|
||||
});
|
||||
|
||||
const marketingBarData = computed(() => {
|
||||
return props.orderStats.by_marketing.map((item, index) => ({
|
||||
name: item.name,
|
||||
total: item.total,
|
||||
count: item.count,
|
||||
}));
|
||||
const cuttingStatusColors = ['#facc15', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-4)'];
|
||||
const cuttingStatusChartConfig = computed(() => {
|
||||
const config: ChartConfig = {};
|
||||
props.cuttingByStatus.forEach((item, index) => {
|
||||
config[item.status] = {
|
||||
label: item.label,
|
||||
color: cuttingStatusColors[index % cuttingStatusColors.length],
|
||||
};
|
||||
});
|
||||
return config;
|
||||
});
|
||||
|
||||
const cashFlowChartConfig = {
|
||||
deposits: {
|
||||
label: 'Pemasukan',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
withdrawals: {
|
||||
label: 'Pengeluaran',
|
||||
color: 'var(--chart-4)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
type SupplierData = { name: string; amount: number };
|
||||
type CustomerData = { name: string; amount: number };
|
||||
type ProductData = { name: string; qty: number };
|
||||
type CashFlowData = { label: string; deposits: number; withdrawals: number };
|
||||
|
||||
const supplierBarData = computed<SupplierData[]>(() => {
|
||||
return props.topSuppliers.map((s) => ({
|
||||
@ -255,6 +334,13 @@ const customerBarData = computed<CustomerData[]>(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
const totalCashBalance = computed(() => {
|
||||
return props.cashAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||
});
|
||||
@ -295,7 +381,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Row 1: Stock Summary -->
|
||||
<!-- Row 1: Stock & Finance Summary -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 1. Raw Material Stock -->
|
||||
<Card>
|
||||
@ -332,45 +418,101 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 5. Purchase Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Pembelian</CardTitle>
|
||||
<TrendingDown class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
{{ formatRupiah(purchaseSummary.total_spent) }}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ purchaseSummary.total_purchases }} transaksi pembelian
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 8. Revenue Summary -->
|
||||
<!-- Total Pendapatan -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Pendapatan</CardTitle>
|
||||
<TrendingUp class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{{ formatRupiah(revenueSummary.total_revenue) }}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ revenueSummary.total_orders }} pesanan selesai
|
||||
<span v-if="revenueSummary.total_discount > 0" class="ml-1 text-orange-500">
|
||||
(potongan: {{ formatRupiah(revenueSummary.total_discount) }})
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Total Pengeluaran -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Pengeluaran</CardTitle>
|
||||
<TrendingDown class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ formatRupiah(purchaseSummary.total_spent + cuttingSummary.total_cost + monthlyExpenses.total) }}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Pembelian + Produksi + Pengeluaran
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Top 5 Charts -->
|
||||
<!-- Row 2: Low Stock Alerts -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- 3. Top 5 Suppliers Chart -->
|
||||
<!-- Low Stock Products -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-sm font-medium">Stok Produk Menipis</CardTitle>
|
||||
<CardDescription>Stok <= {{ 5 }} pcs</CardDescription>
|
||||
</div>
|
||||
<AlertTriangle class="size-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="lowStockProducts.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in lowStockProducts"
|
||||
:key="item.name"
|
||||
class="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<span class="text-sm">{{ item.name }}</span>
|
||||
<Badge variant="outline" class="bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
|
||||
{{ item.stock }} pcs
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground flex h-[80px] items-center justify-center text-sm">
|
||||
Semua stok produk aman
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Low Stock Materials -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle class="text-sm font-medium">Stok Bahan Baku Menipis</CardTitle>
|
||||
<CardDescription>Stok <= 5</CardDescription>
|
||||
</div>
|
||||
<AlertTriangle class="size-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="lowStockMaterials.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in lowStockMaterials"
|
||||
:key="item.name"
|
||||
class="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<span class="text-sm">{{ item.name }}</span>
|
||||
<Badge variant="outline" class="bg-orange-500/10 text-orange-600 border-orange-500/20">
|
||||
{{ item.stock }} {{ item.unit }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground flex h-[80px] items-center justify-center text-sm">
|
||||
Semua stok bahan baku aman
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Top 5 Charts -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Top 5 Suppliers Chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
||||
@ -412,7 +554,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 4. Top 5 Customers Chart -->
|
||||
<!-- Top 5 Customers Chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
||||
@ -453,11 +595,53 @@ const totalCashBalance = computed(() => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Top 5 Products Chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Top 5 Produk</CardTitle>
|
||||
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
v-if="topProducts.length > 0"
|
||||
:config="productChartConfig"
|
||||
class="min-h-[250px] w-full"
|
||||
>
|
||||
<VisXYContainer :data="productBarData">
|
||||
<VisGroupedBar
|
||||
:x="(d: ProductData) => d.name"
|
||||
:y="(d: ProductData) => d.qty"
|
||||
:color="productChartConfig.qty.color"
|
||||
:rounded-corners="4"
|
||||
bar-padding="0.1"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(d: ProductData) => d.name"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="false"
|
||||
/>
|
||||
<VisAxis
|
||||
type="y"
|
||||
:tick-format="(d: number) => ''"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="true"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div v-else class="text-muted-foreground flex h-[250px] items-center justify-center">
|
||||
Belum ada data produk
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Order Stats Charts -->
|
||||
<!-- Row 4: Order Stats Charts -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 7a. Order by Channel -->
|
||||
<!-- Order by Channel -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Pesanan per Channel</CardTitle>
|
||||
@ -493,7 +677,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 7b. Order by Payment Type -->
|
||||
<!-- Order by Payment Type -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Pesanan per Pembayaran</CardTitle>
|
||||
@ -529,7 +713,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 7c. Order by Marketing -->
|
||||
<!-- Order by Marketing -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Pesanan per Marketing</CardTitle>
|
||||
@ -565,7 +749,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 7d. Order by Status -->
|
||||
<!-- Order by Status -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Pesanan per Status</CardTitle>
|
||||
@ -602,12 +786,19 @@ const totalCashBalance = computed(() => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Cutting & Finance -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- 6. Cutting Summary -->
|
||||
<!-- Row 5: Cutting & Cash Flow -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Cutting Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Biaya Produksi & Hasil Cutting</CardTitle>
|
||||
<div>
|
||||
<CardTitle class="text-sm font-medium">Biaya Produksi & Hasil Cutting</CardTitle>
|
||||
<CardDescription v-if="cuttingByStatus.length > 0">
|
||||
<span v-for="(item, index) in cuttingByStatus" :key="item.status">
|
||||
{{ item.label }}: {{ item.count }}<span v-if="index < cuttingByStatus.length - 1"> | </span>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FileText class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -634,45 +825,97 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 8b. Revenue Detail -->
|
||||
<!-- Monthly Cash Flow Chart -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Arus Kas 6 Bulan Terakhir</CardTitle>
|
||||
<CardDescription>Pemasukan vs Pengeluaran kas</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
v-if="monthlyCashFlow.length > 0"
|
||||
:config="cashFlowChartConfig"
|
||||
class="min-h-[200px] w-full"
|
||||
>
|
||||
<VisXYContainer :data="monthlyCashFlow">
|
||||
<VisStackedBar
|
||||
:x="(d: CashFlowData) => d.label"
|
||||
:y="[(d: CashFlowData) => d.deposits, (d: CashFlowData) => -d.withdrawals]"
|
||||
:color="[cashFlowChartConfig.deposits.color, cashFlowChartConfig.withdrawals.color]"
|
||||
:rounded-corners="4"
|
||||
bar-padding="0.1"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(d: CashFlowData) => d.label"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="false"
|
||||
/>
|
||||
<VisAxis
|
||||
type="y"
|
||||
:tick-format="(d: number) => ''"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="true"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
<div class="mt-3 flex justify-center gap-6">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="size-2.5 rounded-full" style="background-color: var(--chart-2)" />
|
||||
<span class="text-muted-foreground text-xs">Pemasukan</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="size-2.5 rounded-full" style="background-color: var(--chart-4)" />
|
||||
<span class="text-muted-foreground text-xs">Pengeluaran</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 6: Revenue Detail, Cash, Expenses -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Revenue Detail -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Detail Pendapatan</CardTitle>
|
||||
<DollarSign class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Total Pendapatan</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ formatRupiah(revenueSummary.total_revenue) }}</p>
|
||||
<p class="text-muted-foreground text-xs">Rata-rata per Pesanan</p>
|
||||
<p class="text-lg font-bold">{{ formatRupiah(revenueSummary.avg_order) }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 border-t">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Total Potongan</p>
|
||||
<p class="text-lg font-semibold text-orange-500">{{ formatRupiah(revenueSummary.total_discount) }}</p>
|
||||
<p class="text-sm font-semibold text-orange-500">{{ formatRupiah(revenueSummary.total_discount) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Total Ongkir</p>
|
||||
<p class="text-lg font-semibold">{{ formatRupiah(revenueSummary.total_shipping) }}</p>
|
||||
<p class="text-sm font-semibold">{{ formatRupiah(revenueSummary.total_shipping) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 9. Cash Accounts -->
|
||||
<!-- Cash Accounts -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Kas Toko</CardTitle>
|
||||
<Banknote class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Total Saldo</p>
|
||||
<p class="text-xl font-bold">{{ formatRupiah(totalCashBalance) }}</p>
|
||||
</div>
|
||||
<div class="space-y-2 pt-2 border-t">
|
||||
<div class="space-y-1 pt-2 border-t">
|
||||
<div
|
||||
v-for="account in cashAccounts"
|
||||
:key="account.name"
|
||||
@ -688,11 +931,80 @@ const totalCashBalance = computed(() => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Monthly Expenses -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Pengeluaran Bulan Ini</CardTitle>
|
||||
<TrendingDown class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ formatRupiah(monthlyExpenses.total) }}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ monthlyExpenses.count }} transaksi pengeluaran
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Employee Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Karyawan</CardTitle>
|
||||
<Users class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ employeeSummary.total }}</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div
|
||||
v-for="item in employeeSummary.by_status"
|
||||
:key="item.status"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||
<span class="font-medium">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 5: HR Summary -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- 10. Kasbon Summary -->
|
||||
<!-- Row 7: HR Summary -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Attendance Today -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Absensi Hari Ini</CardTitle>
|
||||
<UserCheck class="text-muted-foreground size-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">Total Karyawan</p>
|
||||
<p class="text-xl font-bold">{{ attendanceToday.total_employees }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserCheck class="size-4 text-green-500" />
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Hadir</p>
|
||||
<p class="text-lg font-semibold text-green-600">{{ attendanceToday.present }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UserX class="size-4 text-red-500" />
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">Tidak Hadir</p>
|
||||
<p class="text-lg font-semibold text-red-600">{{ attendanceToday.absent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Kasbon Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Kasbon Karyawan</CardTitle>
|
||||
@ -731,7 +1043,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 11. Payroll Summary -->
|
||||
<!-- Payroll Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Penggajian Bulan Ini</CardTitle>
|
||||
@ -767,7 +1079,7 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 12. Leave Request Summary -->
|
||||
<!-- Leave Request Summary -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Pengajuan Cuti Bulan Ini</CardTitle>
|
||||
@ -803,6 +1115,71 @@ const totalCashBalance = computed(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Row 8: Monthly Trends -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Monthly Revenue Trend -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Tren Pendapatan 6 Bulan</CardTitle>
|
||||
<CardDescription>Total pendapatan per bulan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="monthlyRevenueTrend.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in monthlyRevenueTrend"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="text-muted-foreground w-16 text-xs">{{ item.label }}</span>
|
||||
<div class="bg-muted flex-1 overflow-hidden rounded-full h-4">
|
||||
<div
|
||||
class="h-full rounded-full bg-green-500 transition-all"
|
||||
:style="{
|
||||
width: `${Math.max(5, (item.total / Math.max(...monthlyRevenueTrend.map(r => r.total))) * 100)}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="w-24 text-right text-xs font-medium">{{ formatRupiah(item.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground flex h-[150px] items-center justify-center">
|
||||
Belum ada data
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Monthly Purchase Trend -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Tren Pembelian 6 Bulan</CardTitle>
|
||||
<CardDescription>Total pembelian per bulan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="monthlyPurchaseTrend.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in monthlyPurchaseTrend"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="text-muted-foreground w-16 text-xs">{{ item.label }}</span>
|
||||
<div class="bg-muted flex-1 overflow-hidden rounded-full h-4">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all"
|
||||
:style="{
|
||||
width: `${Math.max(5, (item.total / Math.max(...monthlyPurchaseTrend.map(r => r.total))) * 100)}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="w-24 text-right text-xs font-medium">{{ formatRupiah(item.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground flex h-[150px] items-center justify-center">
|
||||
Belum ada data
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -149,7 +149,7 @@ watch(
|
||||
|
||||
<MasterOutOfStockCatalogSection
|
||||
severity="warning"
|
||||
title="Produk Stok Menipis"
|
||||
title="Stok Menipis"
|
||||
description="Hanya produk aktif · stok masih ada, tapi di bawah batas minimum · Klik kartu untuk melihat data."
|
||||
empty-title="Tidak ada peringatan stok"
|
||||
empty-description="Semua varian masih berada di atas batas stok minimum."
|
||||
|
||||
Loading…
Reference in New Issue
Block a user