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(),
|
'revenueSummary' => $this->dashboardService->getRevenueSummary(),
|
||||||
'marketplaceSummary' => $this->dashboardService->getMarketplaceSummary(),
|
'marketplaceSummary' => $this->dashboardService->getMarketplaceSummary(),
|
||||||
'cashAccounts' => $this->dashboardService->getCashAccounts(),
|
'cashAccounts' => $this->dashboardService->getCashAccounts(),
|
||||||
|
'cashSummary' => $this->dashboardService->getCashSummary($startOfMonth, $endOfMonth),
|
||||||
'monthlyCashFlow' => $this->dashboardService->getMonthlyCashFlow(),
|
'monthlyCashFlow' => $this->dashboardService->getMonthlyCashFlow(),
|
||||||
'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startOfMonth, $endOfMonth),
|
'monthlyExpenses' => $this->dashboardService->getMonthlyExpenses($startOfMonth, $endOfMonth),
|
||||||
'kasbonSummary' => $this->dashboardService->getKasbonSummary(),
|
'kasbonSummary' => $this->dashboardService->getKasbonSummary(),
|
||||||
|
|||||||
@ -83,7 +83,6 @@ public function getLowStockProducts(): array
|
|||||||
{
|
{
|
||||||
return ProductVariant::query()
|
return ProductVariant::query()
|
||||||
->where('stock', '<=', ProductVariant::minStock())
|
->where('stock', '<=', ProductVariant::minStock())
|
||||||
->where('stock', '>', 0)
|
|
||||||
->join('products', 'product_variants.product_id', '=', 'products.id')
|
->join('products', 'product_variants.product_id', '=', 'products.id')
|
||||||
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock")
|
->selectRaw("CONCAT(products.name, ' - ', product_variants.name) as full_name, product_variants.stock")
|
||||||
->orderBy('stock')
|
->orderBy('stock')
|
||||||
@ -100,7 +99,6 @@ public function getLowStockMaterials(): array
|
|||||||
{
|
{
|
||||||
return RawMaterialPrice::query()
|
return RawMaterialPrice::query()
|
||||||
->where('stock', '<=', 5)
|
->where('stock', '<=', 5)
|
||||||
->where('stock', '>', 0)
|
|
||||||
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id')
|
->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")
|
->selectRaw("CONCAT(raw_materials.name, ' - ', raw_material_prices.variant) as full_name, raw_material_prices.stock, raw_materials.unit")
|
||||||
->orderBy('stock')
|
->orderBy('stock')
|
||||||
@ -328,6 +326,24 @@ public function getCashAccounts(): array
|
|||||||
->toArray();
|
->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
|
public function getMonthlyCashFlow(): array
|
||||||
{
|
{
|
||||||
$months = collect();
|
$months = collect();
|
||||||
@ -392,6 +408,10 @@ public function getKasbonSummary(): array
|
|||||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
$totalEmployees = EmployeeAdvance::query()
|
||||||
|
->distinct('employee_id')
|
||||||
|
->count('employee_id');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pending' => [
|
'pending' => [
|
||||||
'count' => (int) ($pending->count ?? 0),
|
'count' => (int) ($pending->count ?? 0),
|
||||||
@ -406,6 +426,7 @@ public function getKasbonSummary(): array
|
|||||||
'total' => (int) ($paid->total ?? 0),
|
'total' => (int) ($paid->total ?? 0),
|
||||||
],
|
],
|
||||||
'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (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')
|
->distinct('employee_id')
|
||||||
->count('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 [
|
return [
|
||||||
'total_employees' => $totalEmployees,
|
'total_employees' => $totalEmployees,
|
||||||
'present' => $present,
|
'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 type { Component, InjectionKey, Ref } from 'vue'
|
||||||
|
import { createApp, h, ref } from 'vue'
|
||||||
|
|
||||||
export const ChartConfigSymbol: InjectionKey<Ref<ChartConfig>> = Symbol('chartConfig')
|
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 ChartCrosshair } from './ChartCrosshair.vue'
|
||||||
export { default as ChartLegend } from './ChartLegend.vue'
|
export { default as ChartLegend } from './ChartLegend.vue'
|
||||||
export { default as ChartLegendContent } from './ChartLegendContent.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