feat: refactor dashboard layout by introducing modular StatCard and LowStockCard components
This commit is contained in:
parent
2a4bcae5ce
commit
04ee7c3426
@ -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(),
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
63
resources/js/components/card/LowStockCard.vue
Normal file
63
resources/js/components/card/LowStockCard.vue
Normal 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>
|
||||
60
resources/js/components/card/StatCard.vue
Normal file
60
resources/js/components/card/StatCard.vue
Normal 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>
|
||||
@ -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
Loading…
Reference in New Issue
Block a user