feat: enhance Dashboard component with detailed statistics and charts for improved data visualization and user insights
This commit is contained in:
parent
ee8949947f
commit
56ee409332
@ -2,7 +2,31 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Enums\CashTransactionType;
|
||||||
|
use App\Enums\EmployeeAdvanceStatus;
|
||||||
|
use App\Enums\LeaveRequestStatus;
|
||||||
|
use App\Enums\OrderChannel;
|
||||||
|
use App\Enums\OrderStatus;
|
||||||
|
use App\Enums\PaymentType;
|
||||||
|
use App\Enums\PayrollStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CashAccount;
|
||||||
|
use App\Models\CashTransaction;
|
||||||
|
use App\Models\Cutting;
|
||||||
|
use App\Models\CuttingResult;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\EmployeeAdvance;
|
||||||
|
use App\Models\LeaveRequest;
|
||||||
|
use App\Models\Order;
|
||||||
|
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;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@ -10,6 +34,306 @@ class DashboardController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/Dashboard');
|
$now = Carbon::now();
|
||||||
|
$startOfMonth = $now->copy()->startOfMonth();
|
||||||
|
$endOfMonth = $now->copy()->endOfMonth();
|
||||||
|
|
||||||
|
return Inertia::render('admin/Dashboard', [
|
||||||
|
'rawMaterialStock' => $this->getRawMaterialStock(),
|
||||||
|
'productStock' => $this->getProductStock(),
|
||||||
|
'topSuppliers' => $this->getTopSuppliers(),
|
||||||
|
'topCustomers' => $this->getTopCustomers(),
|
||||||
|
'purchaseSummary' => $this->getPurchaseSummary(),
|
||||||
|
'cuttingSummary' => $this->getCuttingSummary(),
|
||||||
|
'orderStats' => $this->getOrderStats(),
|
||||||
|
'revenueSummary' => $this->getRevenueSummary(),
|
||||||
|
'cashAccounts' => $this->getCashAccounts(),
|
||||||
|
'kasbonSummary' => $this->getKasbonSummary(),
|
||||||
|
'payrollSummary' => $this->getPayrollSummary($startOfMonth, $endOfMonth),
|
||||||
|
'leaveRequestSummary' => $this->getLeaveRequestSummary($startOfMonth, $endOfMonth),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRawMaterialStock(): array
|
||||||
|
{
|
||||||
|
$data = RawMaterialPrice::query()
|
||||||
|
->selectRaw('SUM(stock) as total_stock, SUM(stock * price) as total_value')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_stock' => (float) ($data->total_stock ?? 0),
|
||||||
|
'total_value' => (int) ($data->total_value ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getProductStock(): array
|
||||||
|
{
|
||||||
|
$data = ProductVariant::query()
|
||||||
|
->selectRaw('SUM(stock) as total_stock, SUM(reject_stock) as total_reject')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$totalStock = (int) ($data->total_stock ?? 0);
|
||||||
|
|
||||||
|
$totalValue = Cutting::query()
|
||||||
|
->whereNotNull('cost_per_unit')
|
||||||
|
->join('cutting_results', 'cuttings.id', '=', 'cutting_results.cutting_id')
|
||||||
|
->selectRaw('SUM(cutting_results.warehouse_stock * cuttings.cost_per_unit) as total_value')
|
||||||
|
->value('total_value');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_stock' => $totalStock,
|
||||||
|
'total_reject' => (int) ($data->total_reject ?? 0),
|
||||||
|
'total_value' => (int) ($totalValue ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTopSuppliers(): array
|
||||||
|
{
|
||||||
|
return Purchase::query()
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTopCustomers(): array
|
||||||
|
{
|
||||||
|
return Order::query()
|
||||||
|
->where('status', OrderStatus::COMPLETED)
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPurchaseSummary(): array
|
||||||
|
{
|
||||||
|
$data = Purchase::query()
|
||||||
|
->selectRaw('COUNT(*) as total_purchases, SUM(total) as total_spent, SUM(discount) as total_discount')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_purchases' => (int) ($data->total_purchases ?? 0),
|
||||||
|
'total_spent' => (int) ($data->total_spent ?? 0),
|
||||||
|
'total_discount' => (int) ($data->total_discount ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCuttingSummary(): array
|
||||||
|
{
|
||||||
|
$totalCost = Cutting::query()
|
||||||
|
->selectRaw('COALESCE(SUM(total_material_cost), 0) + COALESCE(SUM(sewing_cost), 0) + COALESCE(SUM(other_cost), 0) as total_cost')
|
||||||
|
->value('total_cost');
|
||||||
|
|
||||||
|
$totalResults = CuttingResult::query()
|
||||||
|
->selectRaw('SUM(cutting_result) as total_cutting, SUM(warehouse_stock) as total_warehouse, SUM(cutting_reject) as total_reject')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_cost' => (int) ($totalCost ?? 0),
|
||||||
|
'total_cutting' => (int) ($totalResults->total_cutting ?? 0),
|
||||||
|
'total_warehouse' => (int) ($totalResults->total_warehouse ?? 0),
|
||||||
|
'total_reject' => (int) ($totalResults->total_reject ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getOrderStats(): array
|
||||||
|
{
|
||||||
|
$byChannel = Order::query()
|
||||||
|
->selectRaw('channel, COUNT(*) as count, SUM(total_amount) as total')
|
||||||
|
->groupBy('channel')
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'channel' => $item->channel instanceof OrderChannel ? $item->channel->value : $item->channel,
|
||||||
|
'label' => $item->channel instanceof OrderChannel ? $item->channel->label() : OrderChannel::from($item->channel)->label(),
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
'total' => (int) $item->total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$byPaymentType = Order::query()
|
||||||
|
->selectRaw('payment_type, COUNT(*) as count, SUM(total_amount) as total')
|
||||||
|
->groupBy('payment_type')
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'payment_type' => $item->payment_type instanceof PaymentType ? $item->payment_type->value : $item->payment_type,
|
||||||
|
'label' => $item->payment_type instanceof PaymentType ? $item->payment_type->label() : PaymentType::from($item->payment_type)->label(),
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
'total' => (int) $item->total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$byMarketing = Order::query()
|
||||||
|
->whereNotNull('marketing_id')
|
||||||
|
->join('users', 'orders.marketing_id', '=', 'users.id')
|
||||||
|
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
|
||||||
|
->selectRaw("COALESCE(user_profiles.full_name, users.username) as name, COUNT(*) as count, SUM(orders.total_amount) as total")
|
||||||
|
->groupBy('orders.marketing_id', 'users.username', 'user_profiles.full_name')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'name' => $item->name,
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
'total' => (int) $item->total,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$byStatus = Order::query()
|
||||||
|
->selectRaw('status, COUNT(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'status' => $item->status instanceof OrderStatus ? $item->status->value : $item->status,
|
||||||
|
'label' => $item->status instanceof OrderStatus ? $item->status->label() : OrderStatus::from($item->status)->label(),
|
||||||
|
'count' => (int) $item->count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'by_channel' => $byChannel->toArray(),
|
||||||
|
'by_payment_type' => $byPaymentType->toArray(),
|
||||||
|
'by_marketing' => $byMarketing->toArray(),
|
||||||
|
'by_status' => $byStatus->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_revenue' => (int) ($data->total_revenue ?? 0),
|
||||||
|
'total_discount' => (int) ($data->total_discount ?? 0),
|
||||||
|
'total_shipping' => (int) ($data->total_shipping ?? 0),
|
||||||
|
'total_orders' => (int) ($data->total_orders ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCashAccounts(): array
|
||||||
|
{
|
||||||
|
return CashAccount::query()
|
||||||
|
->selectRaw('name, balance')
|
||||||
|
->get()
|
||||||
|
->map(fn ($item) => [
|
||||||
|
'name' => $item->name,
|
||||||
|
'balance' => (int) $item->balance,
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getKasbonSummary(): array
|
||||||
|
{
|
||||||
|
$pending = EmployeeAdvance::query()
|
||||||
|
->where('status', EmployeeAdvanceStatus::PENDING)
|
||||||
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$approved = EmployeeAdvance::query()
|
||||||
|
->where('status', EmployeeAdvanceStatus::APPROVED)
|
||||||
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$paid = EmployeeAdvance::query()
|
||||||
|
->where('status', EmployeeAdvanceStatus::PAID)
|
||||||
|
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => [
|
||||||
|
'count' => (int) ($pending->count ?? 0),
|
||||||
|
'total' => (int) ($pending->total ?? 0),
|
||||||
|
],
|
||||||
|
'approved' => [
|
||||||
|
'count' => (int) ($approved->count ?? 0),
|
||||||
|
'total' => (int) ($approved->total ?? 0),
|
||||||
|
],
|
||||||
|
'paid' => [
|
||||||
|
'count' => (int) ($paid->count ?? 0),
|
||||||
|
'total' => (int) ($paid->total ?? 0),
|
||||||
|
],
|
||||||
|
'total' => (int) ($pending->total ?? 0) + (int) ($approved->total ?? 0) + (int) ($paid->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPayrollSummary(Carbon $startOfMonth, Carbon $endOfMonth): array
|
||||||
|
{
|
||||||
|
$period = PayrollPeriod::query()
|
||||||
|
->where('year', $startOfMonth->year)
|
||||||
|
->where('month', $startOfMonth->month)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $period) {
|
||||||
|
return [
|
||||||
|
'has_period' => false,
|
||||||
|
'total_employees' => 0,
|
||||||
|
'total_amount' => 0,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 0,
|
||||||
|
'paid_count' => 0,
|
||||||
|
'unpaid_count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payrolls = Payroll::query()
|
||||||
|
->where('payroll_period_id', $period->id)
|
||||||
|
->selectRaw("
|
||||||
|
COUNT(*) as total_employees,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) as paid_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'unpaid' THEN total_amount ELSE 0 END), 0) as unpaid_amount,
|
||||||
|
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count,
|
||||||
|
SUM(CASE WHEN status = 'unpaid' THEN 1 ELSE 0 END) as unpaid_count
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'has_period' => true,
|
||||||
|
'period_status' => $period->status->value,
|
||||||
|
'total_employees' => (int) ($payrolls->total_employees ?? 0),
|
||||||
|
'total_amount' => (int) ($payrolls->total_amount ?? 0),
|
||||||
|
'paid_amount' => (int) ($payrolls->paid_amount ?? 0),
|
||||||
|
'unpaid_amount' => (int) ($payrolls->unpaid_amount ?? 0),
|
||||||
|
'paid_count' => (int) ($payrolls->paid_count ?? 0),
|
||||||
|
'unpaid_count' => (int) ($payrolls->unpaid_count ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLeaveRequestSummary(Carbon $startOfMonth, Carbon $endOfMonth): array
|
||||||
|
{
|
||||||
|
$data = LeaveRequest::query()
|
||||||
|
->where(function ($q) use ($startOfMonth, $endOfMonth) {
|
||||||
|
$q->whereBetween('start_date', [$startOfMonth, $endOfMonth])
|
||||||
|
->orWhereBetween('end_date', [$startOfMonth, $endOfMonth]);
|
||||||
|
})
|
||||||
|
->selectRaw("
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||||
|
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||||
|
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (int) ($data->total ?? 0),
|
||||||
|
'pending' => (int) ($data->pending_count ?? 0),
|
||||||
|
'approved' => (int) ($data->approved_count ?? 0),
|
||||||
|
'rejected' => (int) ($data->rejected_count ?? 0),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1669
package-lock.json
generated
1669
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -46,6 +46,8 @@
|
|||||||
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
|
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@unovis/ts": "^1.6.6",
|
||||||
|
"@unovis/vue": "^1.6.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
19
resources/js/components/ui/chart/ChartContainer.vue
Normal file
19
resources/js/components/ui/chart/ChartContainer.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { provide, toRef } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ChartConfigSymbol, type ChartConfig } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
config: ChartConfig
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
provide(ChartConfigSymbol, toRef(props, 'config'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex aspect-video justify-center text-xs [&>div]:w-full', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
resources/js/components/ui/chart/ChartCrosshair.vue
Normal file
18
resources/js/components/ui/chart/ChartCrosshair.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { VisCrosshair } from '@unovis/vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
template?: any
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
color?: any
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VisCrosshair
|
||||||
|
:template="template"
|
||||||
|
:color="color"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
6
resources/js/components/ui/chart/ChartLegend.vue
Normal file
6
resources/js/components/ui/chart/ChartLegend.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
26
resources/js/components/ui/chart/ChartLegendContent.vue
Normal file
26
resources/js/components/ui/chart/ChartLegendContent.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ChartConfigSymbol } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nameKey?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const config = inject(ChartConfigSymbol)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<template v-for="(item, key) in config" :key="key">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color ?? item.theme?.light }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{{ item.label ?? key }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
6
resources/js/components/ui/chart/ChartTooltip.vue
Normal file
6
resources/js/components/ui/chart/ChartTooltip.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
65
resources/js/components/ui/chart/ChartTooltipContent.vue
Normal file
65
resources/js/components/ui/chart/ChartTooltipContent.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { ChartConfigSymbol, type ChartConfig } from '.'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
labelKey?: string
|
||||||
|
nameKey?: string
|
||||||
|
indicator?: 'dot' | 'line' | 'dashed'
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
payload?: Array<{ name: string; value: number; color: string; dataKey: string }>
|
||||||
|
label?: string
|
||||||
|
class?: string
|
||||||
|
}>(), {
|
||||||
|
indicator: 'dot',
|
||||||
|
hideLabel: false,
|
||||||
|
hideIndicator: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = inject(ChartConfigSymbol)
|
||||||
|
|
||||||
|
function getPayloadConfig(key: string) {
|
||||||
|
return config?.value?.[key]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border bg-background px-3 py-2 shadow-xl">
|
||||||
|
<template v-if="!hideLabel && label">
|
||||||
|
<div class="mb-1 font-medium">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<template v-for="item in payload" :key="item.name">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<template v-if="!hideIndicator">
|
||||||
|
<span
|
||||||
|
v-if="indicator === 'dot'"
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="indicator === 'line'"
|
||||||
|
class="h-0.5 w-4"
|
||||||
|
:style="{ backgroundColor: item.color }"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="h-0.5 w-4 border-t-2 border-dashed"
|
||||||
|
:style="{ borderColor: item.color }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{{ getPayloadConfig(item.dataKey)?.label ?? item.name }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto font-medium tabular-nums text-foreground">
|
||||||
|
{{ item.value.toLocaleString('id-ID') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
resources/js/components/ui/chart/index.ts
Normal file
19
resources/js/components/ui/chart/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Component, InjectionKey, Ref } from 'vue'
|
||||||
|
|
||||||
|
export const ChartConfigSymbol: InjectionKey<Ref<ChartConfig>> = Symbol('chartConfig')
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[key in string]: {
|
||||||
|
label?: string
|
||||||
|
icon?: Component
|
||||||
|
color?: string
|
||||||
|
theme?: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { default as ChartContainer } from './ChartContainer.vue'
|
||||||
|
export { default as ChartTooltip } from './ChartTooltip.vue'
|
||||||
|
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'
|
||||||
@ -1,12 +1,118 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { Clock, Sun, Sunrise, Moon } from '@lucide/vue';
|
import {
|
||||||
|
Banknote,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
Moon,
|
||||||
|
Package,
|
||||||
|
ShoppingCart,
|
||||||
|
Sun,
|
||||||
|
Sunrise,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from '@lucide/vue';
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 type { ChartConfig } from '@/components/ui/chart';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
|
||||||
const page = usePage();
|
interface DashboardProps {
|
||||||
const user = computed(() => page.props.auth.user);
|
rawMaterialStock: {
|
||||||
|
total_stock: number;
|
||||||
|
total_value: number;
|
||||||
|
};
|
||||||
|
productStock: {
|
||||||
|
total_stock: number;
|
||||||
|
total_reject: number;
|
||||||
|
total_value: number;
|
||||||
|
};
|
||||||
|
topSuppliers: Array<{
|
||||||
|
name: string;
|
||||||
|
total_amount: number;
|
||||||
|
purchase_count: number;
|
||||||
|
}>;
|
||||||
|
topCustomers: Array<{
|
||||||
|
name: string;
|
||||||
|
total_amount: number;
|
||||||
|
order_count: number;
|
||||||
|
}>;
|
||||||
|
purchaseSummary: {
|
||||||
|
total_purchases: number;
|
||||||
|
total_spent: number;
|
||||||
|
total_discount: number;
|
||||||
|
};
|
||||||
|
cuttingSummary: {
|
||||||
|
total_cost: number;
|
||||||
|
total_cutting: number;
|
||||||
|
total_warehouse: number;
|
||||||
|
total_reject: number;
|
||||||
|
};
|
||||||
|
orderStats: {
|
||||||
|
by_channel: Array<{
|
||||||
|
channel: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
by_payment_type: Array<{
|
||||||
|
payment_type: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
by_marketing: Array<{
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
by_status: Array<{
|
||||||
|
status: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
revenueSummary: {
|
||||||
|
total_revenue: number;
|
||||||
|
total_discount: number;
|
||||||
|
total_shipping: number;
|
||||||
|
total_orders: number;
|
||||||
|
};
|
||||||
|
cashAccounts: Array<{
|
||||||
|
name: string;
|
||||||
|
balance: number;
|
||||||
|
}>;
|
||||||
|
kasbonSummary: {
|
||||||
|
pending: { count: number; total: number };
|
||||||
|
approved: { count: number; total: number };
|
||||||
|
paid: { count: number; total: number };
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
payrollSummary: {
|
||||||
|
has_period: boolean;
|
||||||
|
period_status?: string;
|
||||||
|
total_employees: number;
|
||||||
|
total_amount: number;
|
||||||
|
paid_amount: number;
|
||||||
|
unpaid_amount: number;
|
||||||
|
paid_count: number;
|
||||||
|
unpaid_count: number;
|
||||||
|
};
|
||||||
|
leaveRequestSummary: {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<DashboardProps>();
|
||||||
|
|
||||||
const currentTime = ref(new Date());
|
const currentTime = ref(new Date());
|
||||||
let timer: ReturnType<typeof setInterval>;
|
let timer: ReturnType<typeof setInterval>;
|
||||||
@ -56,14 +162,109 @@ const greeting = computed(() => {
|
|||||||
|
|
||||||
return { text: 'Selamat Malam', icon: Moon };
|
return { text: 'Selamat Malam', icon: Moon };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatRupiah(value: number): string {
|
||||||
|
return 'Rp ' + value.toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 channelColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)'];
|
||||||
|
const channelChartConfig = computed(() => {
|
||||||
|
const config: ChartConfig = {};
|
||||||
|
props.orderStats.by_channel.forEach((item, index) => {
|
||||||
|
config[item.channel] = {
|
||||||
|
label: item.label,
|
||||||
|
color: channelColors[index % channelColors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)'];
|
||||||
|
const paymentChartConfig = computed(() => {
|
||||||
|
const config: ChartConfig = {};
|
||||||
|
props.orderStats.by_payment_type.forEach((item, index) => {
|
||||||
|
config[item.payment_type] = {
|
||||||
|
label: item.label,
|
||||||
|
color: paymentColors[index % paymentColors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColors = ['#facc15', 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-4)'];
|
||||||
|
const statusChartConfig = computed(() => {
|
||||||
|
const config: ChartConfig = {};
|
||||||
|
props.orderStats.by_status.forEach((item, index) => {
|
||||||
|
config[item.status] = {
|
||||||
|
label: item.label,
|
||||||
|
color: statusColors[index % statusColors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketingColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];
|
||||||
|
const marketingChartConfig = computed(() => {
|
||||||
|
const config: ChartConfig = {};
|
||||||
|
props.orderStats.by_marketing.forEach((item, index) => {
|
||||||
|
config[`mkt_${index}`] = {
|
||||||
|
label: item.name,
|
||||||
|
color: marketingColors[index % marketingColors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketingBarData = computed(() => {
|
||||||
|
return props.orderStats.by_marketing.map((item, index) => ({
|
||||||
|
name: item.name,
|
||||||
|
total: item.total,
|
||||||
|
count: item.count,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
type SupplierData = { name: string; amount: number };
|
||||||
|
type CustomerData = { name: string; amount: 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 totalCashBalance = computed(() => {
|
||||||
|
return props.cashAccounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<div class="flex flex-1 flex-col gap-4">
|
<div class="flex flex-1 flex-col gap-6">
|
||||||
<!-- Welcome Card -->
|
<!-- Welcome Card -->
|
||||||
<Card class="relative overflow-hidden">
|
<Card class="relative overflow-hidden">
|
||||||
<div class="from-primary/5 to-background absolute inset-0 bg-linear-to-br" />
|
<div class="from-primary/5 to-background absolute inset-0 bg-linear-to-br" />
|
||||||
@ -74,7 +275,7 @@ const greeting = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle class="text-2xl font-bold">
|
<CardTitle class="text-2xl font-bold">
|
||||||
{{ greeting.text }}, {{ user?.username }}!
|
{{ greeting.text }}, {{ $page.props.auth.user?.username }}!
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription class="mt-1">
|
<CardDescription class="mt-1">
|
||||||
Selamat datang di dasbor aplikasi.
|
Selamat datang di dasbor aplikasi.
|
||||||
@ -85,7 +286,7 @@ const greeting = computed(() => {
|
|||||||
<CardContent class="relative">
|
<CardContent class="relative">
|
||||||
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
||||||
<span>{{ dateStr }}</span>
|
<span>{{ dateStr }}</span>
|
||||||
<span>•</span>
|
<span>-</span>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Clock class="size-3.5" />
|
<Clock class="size-3.5" />
|
||||||
{{ timeStr }}
|
{{ timeStr }}
|
||||||
@ -94,11 +295,513 @@ const greeting = computed(() => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Placeholder for future content -->
|
<!-- Row 1: Stock Summary -->
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="bg-muted/50 aspect-video rounded-xl" />
|
<!-- 1. Raw Material Stock -->
|
||||||
<div class="bg-muted/50 aspect-video rounded-xl" />
|
<Card>
|
||||||
<div class="bg-muted/50 aspect-video rounded-xl" />
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">Stok Bahan Baku</CardTitle>
|
||||||
|
<Package class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ rawMaterialStock.total_stock.toLocaleString('id-ID') }}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Total nilai: {{ formatRupiah(rawMaterialStock.total_value) }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 2. Product Stock -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">Stok Produk</CardTitle>
|
||||||
|
<ShoppingCart class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ productStock.total_stock.toLocaleString('id-ID') }}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Total nilai: {{ formatRupiah(productStock.total_value) }}
|
||||||
|
<span v-if="productStock.total_reject > 0" class="ml-1 text-red-500">
|
||||||
|
({{ productStock.total_reject }} reject)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</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 -->
|
||||||
|
<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">
|
||||||
|
{{ 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Top 5 Charts -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<!-- 3. Top 5 Suppliers Chart -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
||||||
|
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer
|
||||||
|
v-if="topSuppliers.length > 0"
|
||||||
|
:config="supplierChartConfig"
|
||||||
|
class="min-h-[250px] w-full"
|
||||||
|
>
|
||||||
|
<VisXYContainer :data="supplierBarData">
|
||||||
|
<VisGroupedBar
|
||||||
|
:x="(d: SupplierData) => d.name"
|
||||||
|
:y="(d: SupplierData) => d.amount"
|
||||||
|
:color="supplierChartConfig.amount.color"
|
||||||
|
:rounded-corners="4"
|
||||||
|
bar-padding="0.1"
|
||||||
|
/>
|
||||||
|
<VisAxis
|
||||||
|
type="x"
|
||||||
|
:x="(d: SupplierData) => 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 supplier
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 4. Top 5 Customers Chart -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
||||||
|
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer
|
||||||
|
v-if="topCustomers.length > 0"
|
||||||
|
:config="customerChartConfig"
|
||||||
|
class="min-h-[250px] w-full"
|
||||||
|
>
|
||||||
|
<VisXYContainer :data="customerBarData">
|
||||||
|
<VisGroupedBar
|
||||||
|
:x="(d: CustomerData) => d.name"
|
||||||
|
:y="(d: CustomerData) => d.amount"
|
||||||
|
:color="customerChartConfig.amount.color"
|
||||||
|
:rounded-corners="4"
|
||||||
|
bar-padding="0.1"
|
||||||
|
/>
|
||||||
|
<VisAxis
|
||||||
|
type="x"
|
||||||
|
:x="(d: CustomerData) => 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 pelanggan
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Order Stats Charts -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- 7a. Order by Channel -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Pesanan per Channel</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="orderStats.by_channel.length > 0">
|
||||||
|
<ChartContainer :config="channelChartConfig" class="min-h-[200px] w-full">
|
||||||
|
<VisXYContainer :data="orderStats.by_channel">
|
||||||
|
<VisDonut
|
||||||
|
:value="(d: typeof orderStats.by_channel[number]) => d.count"
|
||||||
|
:color="orderStats.by_channel.map((_, i) => channelColors[i % channelColors.length])"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in orderStats.by_channel"
|
||||||
|
:key="item.channel"
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: channelColors[index % channelColors.length] }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">{{ item.label }}</span>
|
||||||
|
<span class="text-xs font-medium">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||||
|
Belum ada data
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 7b. Order by Payment Type -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Pesanan per Pembayaran</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="orderStats.by_payment_type.length > 0">
|
||||||
|
<ChartContainer :config="paymentChartConfig" class="min-h-[200px] w-full">
|
||||||
|
<VisXYContainer :data="orderStats.by_payment_type">
|
||||||
|
<VisDonut
|
||||||
|
:value="(d: typeof orderStats.by_payment_type[number]) => d.count"
|
||||||
|
:color="orderStats.by_payment_type.map((_, i) => paymentColors[i % paymentColors.length])"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in orderStats.by_payment_type"
|
||||||
|
:key="item.payment_type"
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: paymentColors[index % paymentColors.length] }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">{{ item.label }}</span>
|
||||||
|
<span class="text-xs font-medium">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||||
|
Belum ada data
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 7c. Order by Marketing -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Pesanan per Marketing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="orderStats.by_marketing.length > 0">
|
||||||
|
<ChartContainer :config="marketingChartConfig" class="min-h-[200px] w-full">
|
||||||
|
<VisXYContainer :data="orderStats.by_marketing">
|
||||||
|
<VisDonut
|
||||||
|
:value="(d: typeof orderStats.by_marketing[number]) => d.count"
|
||||||
|
:color="orderStats.by_marketing.map((_, i) => marketingColors[i % marketingColors.length])"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in orderStats.by_marketing"
|
||||||
|
:key="item.name"
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: marketingColors[index % marketingColors.length] }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">{{ item.name }}</span>
|
||||||
|
<span class="text-xs font-medium">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||||
|
Belum ada data
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 7d. Order by Status -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Pesanan per Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="orderStats.by_status.length > 0">
|
||||||
|
<ChartContainer :config="statusChartConfig" class="min-h-[200px] w-full">
|
||||||
|
<VisXYContainer :data="orderStats.by_status">
|
||||||
|
<VisDonut
|
||||||
|
:value="(d: typeof orderStats.by_status[number]) => d.count"
|
||||||
|
:color="orderStats.by_status.map((_, i) => statusColors[i % statusColors.length])"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in orderStats.by_status"
|
||||||
|
:key="item.status"
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-full"
|
||||||
|
:style="{ backgroundColor: statusColors[index % statusColors.length] }"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">{{ item.label }}</span>
|
||||||
|
<span class="text-xs font-medium">{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||||
|
Belum ada data
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: Cutting & Finance -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- 6. 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>
|
||||||
|
<FileText class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Biaya Produksi</p>
|
||||||
|
<p class="text-xl font-bold">{{ formatRupiah(cuttingSummary.total_cost) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Hasil Cutting</p>
|
||||||
|
<p class="text-lg font-semibold">{{ cuttingSummary.total_cutting.toLocaleString('id-ID') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Masuk Gudang</p>
|
||||||
|
<p class="text-lg font-semibold">{{ cuttingSummary.total_warehouse.toLocaleString('id-ID') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Reject</p>
|
||||||
|
<p class="text-lg font-semibold text-red-500">{{ cuttingSummary.total_reject.toLocaleString('id-ID') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 8b. 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>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Pendapatan</p>
|
||||||
|
<p class="text-xl font-bold text-green-600">{{ formatRupiah(revenueSummary.total_revenue) }}</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Ongkir</p>
|
||||||
|
<p class="text-lg font-semibold">{{ formatRupiah(revenueSummary.total_shipping) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 9. 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>
|
||||||
|
<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
|
||||||
|
v-for="account in cashAccounts"
|
||||||
|
:key="account.name"
|
||||||
|
class="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground text-sm">{{ account.name }}</span>
|
||||||
|
<span class="text-sm font-medium">{{ formatRupiah(account.balance) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="cashAccounts.length === 0" class="text-muted-foreground text-sm">
|
||||||
|
Belum ada akun kas
|
||||||
|
</div>
|
||||||
|
</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 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle class="text-sm font-medium">Kasbon Karyawan</CardTitle>
|
||||||
|
<CreditCard class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Keseluruhan</p>
|
||||||
|
<p class="text-xl font-bold">{{ formatRupiah(kasbonSummary.total) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
|
||||||
|
Menunggu
|
||||||
|
</Badge>
|
||||||
|
<p class="text-sm font-semibold">{{ kasbonSummary.pending.count }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ formatRupiah(kasbonSummary.pending.total) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-blue-500/10 text-blue-600 border-blue-500/20">
|
||||||
|
Disetujui
|
||||||
|
</Badge>
|
||||||
|
<p class="text-sm font-semibold">{{ kasbonSummary.approved.count }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ formatRupiah(kasbonSummary.approved.total) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-green-500/10 text-green-600 border-green-500/20">
|
||||||
|
Dibayar
|
||||||
|
</Badge>
|
||||||
|
<p class="text-sm font-semibold">{{ kasbonSummary.paid.count }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ formatRupiah(kasbonSummary.paid.total) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 11. 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>
|
||||||
|
<Users class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="payrollSummary.has_period" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Penggajian</p>
|
||||||
|
<p class="text-xl font-bold">{{ formatRupiah(payrollSummary.total_amount) }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ payrollSummary.total_employees }} karyawan</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 pt-2 border-t">
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-green-500/10 text-green-600 border-green-500/20">
|
||||||
|
Sudah Dibayar
|
||||||
|
</Badge>
|
||||||
|
<p class="text-sm font-semibold">{{ payrollSummary.paid_count }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ formatRupiah(payrollSummary.paid_amount) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-red-500/10 text-red-600 border-red-500/20">
|
||||||
|
Belum Dibayar
|
||||||
|
</Badge>
|
||||||
|
<p class="text-sm font-semibold">{{ payrollSummary.unpaid_count }}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ formatRupiah(payrollSummary.unpaid_amount) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground flex h-[120px] items-center justify-center">
|
||||||
|
Belum ada periode payroll bulan ini
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 12. 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>
|
||||||
|
<FileText class="text-muted-foreground size-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground text-xs">Total Pengajuan</p>
|
||||||
|
<p class="text-xl font-bold">{{ leaveRequestSummary.total }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 pt-2 border-t">
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
|
||||||
|
Menunggu
|
||||||
|
</Badge>
|
||||||
|
<p class="text-lg font-semibold">{{ leaveRequestSummary.pending }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-green-500/10 text-green-600 border-green-500/20">
|
||||||
|
Disetujui
|
||||||
|
</Badge>
|
||||||
|
<p class="text-lg font-semibold">{{ leaveRequestSummary.approved }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" class="mb-1 bg-red-500/10 text-red-600 border-red-500/20">
|
||||||
|
Ditolak
|
||||||
|
</Badge>
|
||||||
|
<p class="text-lg font-semibold">{{ leaveRequestSummary.rejected }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user