feat: refactor dashboard layout by introducing modular StatCard and LowStockCard components

This commit is contained in:
Yoga Pangestu 2026-06-23 14:01:07 +07:00
parent 2a4bcae5ce
commit 04ee7c3426
6 changed files with 843 additions and 583 deletions

View File

@ -35,6 +35,7 @@ public function index(): Response
'revenueSummary' => $this->dashboardService->getRevenueSummary(),
'marketplaceSummary' => $this->dashboardService->getMarketplaceSummary(),
'cashAccounts' => $this->dashboardService->getCashAccounts(),
'cashSummary' => $this->dashboardService->getCashSummary($startOfMonth, $endOfMonth),
'monthlyCashFlow' => $this->dashboardService->getMonthlyCashFlow(),
'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startOfMonth, $endOfMonth),
'kasbonSummary' => $this->dashboardService->getKasbonSummary(),

View File

@ -83,7 +83,6 @@ public 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')
@ -100,7 +99,6 @@ public 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')
@ -328,6 +326,24 @@ public function getCashAccounts(): array
->toArray();
}
public function getCashSummary(Carbon $startOfMonth, Carbon $endOfMonth): array
{
$summary = CashTransaction::query()
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->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_transactions' => (int) ($summary->total_transactions ?? 0),
'total_deposit' => (int) ($summary->total_deposit ?? 0),
'total_withdrawal' => (int) ($summary->total_withdrawal ?? 0),
];
}
public function getMonthlyCashFlow(): array
{
$months = collect();
@ -392,6 +408,10 @@ public function getKasbonSummary(): array
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
->first();
$totalEmployees = EmployeeAdvance::query()
->distinct('employee_id')
->count('employee_id');
return [
'pending' => [
'count' => (int) ($pending->count ?? 0),
@ -406,6 +426,7 @@ public function getKasbonSummary(): array
'total' => (int) ($paid->total ?? 0),
],
'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (int) ($paid->total ?? 0),
'total_employees' => $totalEmployees,
];
}
@ -506,10 +527,23 @@ public function getAttendanceToday(): array
->distinct('employee_id')
->count('employee_id');
// Karyawan yang sedang cuti hari ini (approved & aktif hari ini)
$onLeave = LeaveRequest::query()
->approved()
->where('start_date', '<=', $today)
->where('end_date', '>=', $today)
->distinct('employee_id')
->count('employee_id');
// Absent = tidak hadir & tidak sedang cuti
$notPresent = max(0, $totalEmployees - $present);
$absent = max(0, $notPresent - $onLeave);
return [
'total_employees' => $totalEmployees,
'present' => $present,
'absent' => max(0, $totalEmployees - $present),
'absent' => $absent,
'on_leave' => $onLeave,
];
}

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue';
interface LowStockItem {
name: string;
stock: number;
unit?: string;
}
interface Props {
title: string;
icon?: Component;
iconColorClass?: string;
items: LowStockItem[];
emptyText: string;
badgeColorClass?: string;
}
withDefaults(defineProps<Props>(), {
iconColorClass: 'text-muted-foreground',
badgeColorClass: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-600',
});
</script>
<template>
<Card>
<CardHeader
class="flex flex-row items-center justify-between space-y-0 pb-2"
>
<div>
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
</div>
<component
v-if="icon"
:is="icon"
class="size-4"
:class="iconColorClass"
/>
</CardHeader>
<CardContent>
<div v-if="items.length > 0" class="space-y-2">
<div
v-for="item in items"
: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="badgeColorClass">
{{ item.stock }} {{ item.unit || 'pcs' }}
</Badge>
</div>
</div>
<div
v-else
class="flex h-[80px] items-center justify-center text-sm text-muted-foreground"
>
{{ emptyText }}
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { Component } from 'vue';
interface StatItem {
label: string;
value: string | number;
color?: string; // tailwind text color class, e.g. 'text-green-600'
}
interface Props {
title: string;
icon?: Component;
mainLabel?: string;
mainValue: string | number;
subLabel?: string;
items: StatItem[];
cols?: 2 | 3;
}
withDefaults(defineProps<Props>(), {
cols: 3,
});
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ title }}</CardTitle>
<component v-if="icon" :is="icon" class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="space-y-3">
<div>
<p v-if="mainLabel" class="text-xs text-muted-foreground">
{{ mainLabel }}
</p>
<p class="py-2 text-xl font-bold">{{ mainValue }}</p>
<p v-if="subLabel" class="text-xs text-muted-foreground">
{{ subLabel }}
</p>
</div>
<div
class="border-t pt-2 text-center"
:class="cols === 2 ? 'grid grid-cols-2 gap-2' : 'grid grid-cols-3 gap-2'"
>
<div v-for="item in items" :key="item.label">
<p class="text-xs text-muted-foreground">{{ item.label }}</p>
<p
class="text-sm font-semibold"
:class="item.color ?? ''"
>
{{ item.value }}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -1,4 +1,5 @@
import type { Component, InjectionKey, Ref } from 'vue'
import { createApp, h, ref } from 'vue'
export const ChartConfigSymbol: InjectionKey<Ref<ChartConfig>> = Symbol('chartConfig')
@ -17,3 +18,22 @@ export { default as ChartTooltipContent } from './ChartTooltipContent.vue'
export { default as ChartCrosshair } from './ChartCrosshair.vue'
export { default as ChartLegend } from './ChartLegend.vue'
export { default as ChartLegendContent } from './ChartLegendContent.vue'
export function componentToString(
config: ChartConfig,
component: Component,
props?: any,
) {
if (typeof window === 'undefined') return ''
const container = document.createElement('div')
const app = createApp({
render() {
return h(component, props)
},
})
app.provide(ChartConfigSymbol, ref(config))
app.mount(container)
const html = container.innerHTML
app.unmount()
return html
}

File diff suppressed because it is too large Load Diff