dress/resources/js/pages/analysis.tsx

443 lines
18 KiB
TypeScript

import { StatCard } from '@/components/cards/stat-card';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formatDate } from '@/lib/formatters';
import { analysis } from '@/routes';
import type { AnalysisPageProps } from '@/types';
import { Head, router, usePage } from '@inertiajs/react';
import { CalendarIcon, Filter, FilterX } from 'lucide-react';
import { useState } from 'react';
import { AovTrendChart } from '../components/charts/aov-trend-chart';
import { CustomBarChart } from '../components/charts/bar-chart';
import { KnobChart } from '../components/charts/knob-chart';
import { CustomPieChart } from '../components/charts/pie-chart';
import { RevenueVsProfitChart } from '../components/charts/revenue-vs-profit-chart';
import { RevenueVsPurchasesChart } from '../components/charts/revenue-vs-purchases-chart';
import { SalesByHourChart } from '../components/charts/sales-by-hour-chart';
import { TransactionVolumeChart } from '../components/charts/transaction-volume-chart';
export default function Analisa() {
const {
stats,
salesByMonth,
revenueVsProfit,
transactionVolume,
salesByHour,
paymentMethods,
orderStatuses,
orderChannels,
topProducts,
topCustomers,
topCategories,
filters,
} = usePage<AnalysisPageProps>().props;
const [period, setPeriod] = useState(filters.period || 'all');
const [startDate, setStartDate] = useState(filters.start_date || '');
const [endDate, setEndDate] = useState(filters.end_date || '');
const [isStartOpen, setIsStartOpen] = useState(false);
const [isEndOpen, setIsEndOpen] = useState(false);
const formatToYmd = (date: Date) => {
return (
date.getFullYear() +
'-' +
String(date.getMonth() + 1).padStart(2, '0') +
'-' +
String(date.getDate()).padStart(2, '0')
);
};
const applyFilters = (newParams: any) => {
router.get(
analysis().url,
{
...filters,
...newParams,
},
{
preserveState: true,
preserveScroll: true,
},
);
};
const handlePeriodChange = (value: string) => {
setPeriod(value);
setStartDate('');
setEndDate('');
applyFilters({ period: value, start_date: '', end_date: '' });
};
const handleCustomFilter = () => {
if (startDate && endDate) {
setPeriod('custom');
applyFilters({
period: 'custom',
start_date: startDate,
end_date: endDate,
});
}
};
const clearFilters = () => {
setPeriod('all');
setStartDate('');
setEndDate('');
applyFilters({ period: 'all', start_date: '', end_date: '' });
};
const getFilterDescription = (baseDesc: string) => {
if (filters.start_date && filters.end_date) {
return `${baseDesc} (Periode: ${filters.start_date} s/d ${filters.end_date})`;
}
switch (filters.period) {
case 'week':
return `${baseDesc}`;
case 'month':
return `${baseDesc}`;
case 'year':
return `${baseDesc}`;
default:
return `${baseDesc}`;
}
};
return (
<>
<Head title="Analisa" />
<div className="@container/main flex h-full min-h-screen flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Analisa
</h1>
<p className="text-sm text-muted-foreground">
Lihat performa bisnis Anda secara mendalam.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={period}
onValueChange={handlePeriodChange}
>
<SelectTrigger className="w-[140px] bg-card">
<SelectValue placeholder="Pilih Periode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Waktu</SelectItem>
<SelectItem value="week">Pekan Ini</SelectItem>
<SelectItem value="month">Bulan Ini</SelectItem>
<SelectItem value="year">Tahun Ini</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2 rounded-md border bg-card p-1">
<Popover
open={isStartOpen}
onOpenChange={setIsStartOpen}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-[140px] justify-start px-2 font-normal"
>
<CalendarIcon className="mr-2 h-3 w-3 text-muted-foreground" />
<span className="truncate">
{startDate
? formatDate(startDate)
: 'Mulai'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
selected={
startDate
? new Date(startDate)
: undefined
}
onSelect={(date) => {
setStartDate(
date ? formatToYmd(date) : '',
);
setIsStartOpen(false);
}}
captionLayout="dropdown"
initialFocus
/>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground">
s/d
</span>
<Popover
open={isEndOpen}
onOpenChange={setIsEndOpen}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-[140px] justify-start px-2 font-normal"
>
<CalendarIcon className="mr-2 h-3 w-3 text-muted-foreground" />
<span className="truncate">
{endDate
? formatDate(endDate)
: 'Selesai'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
>
<Calendar
mode="single"
selected={
endDate
? new Date(endDate)
: undefined
}
onSelect={(date) => {
setEndDate(
date ? formatToYmd(date) : '',
);
setIsEndOpen(false);
}}
captionLayout="dropdown"
initialFocus
/>
</PopoverContent>
</Popover>
<Button
size="sm"
variant="default"
className="h-8 px-3"
onClick={handleCustomFilter}
disabled={!startDate || !endDate}
>
<Filter className="mr-2 h-4 w-4" />
Terapkan
</Button>
</div>
{(period !== 'all' || (startDate && endDate)) && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="h-9"
>
<FilterX className="mr-2 h-4 w-4" />
Reset
</Button>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-2 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs @sm/main:grid-cols-2 @3xl/main:grid-cols-3 @5xl/main:grid-cols-4 @7xl/main:grid-cols-6 dark:*:data-[slot=card]:bg-card">
{[
{
title: 'Total Penjualan',
stat: stats.total_sales,
isCurrency: false,
},
{
title: 'Total Pendapatan',
stat: stats.total_revenue,
},
{
title: 'Total Pengeluaran',
stat: stats.total_expenses,
},
{ title: 'HPP (Modal)', stat: stats.cogs },
{ title: 'AOV (Avg Order Value)', stat: stats.aov },
{
title: 'Profit Margin',
stat: stats.profit_margin,
isPercentage: true,
},
{ title: 'Total Diskon', stat: stats.total_discount },
{ title: 'Laba Kotor', stat: stats.gross_profit },
{ title: 'Laba Bersih', stat: stats.net_profit },
{ title: 'Total Gaji', stat: stats.total_payrolls },
{
title: 'Total Pembelian',
stat: stats.total_purchases,
},
{
title: 'Produk Terjual',
stat: stats.products_sold,
isCurrency: false,
},
{
title: 'Total Pelanggan',
stat: stats.total_customers,
isCurrency: false,
},
].map((item, index) => (
<div
key={index}
className="animate-in fill-mode-both fade-in slide-in-from-bottom-4"
style={{ animationDelay: `${index * 100}ms` }}
>
<StatCard
{...item}
className="transition-all duration-300 hover:-translate-y-1 hover:scale-[1.03] hover:shadow-xl"
/>
</div>
))}
</div>
{/* Main Trends Grid */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<RevenueVsProfitChart
data={revenueVsProfit}
title="Pendapatan vs Laba"
description={getFilterDescription(
'Analisis pendapatan kotor dibandingkan dengan laba bersih.',
)}
/>
<RevenueVsPurchasesChart
data={salesByMonth}
title="Penjualan vs Belanja"
description={getFilterDescription(
'Perbandingan total nilai penjualan dan belanja stok barang.',
)}
/>
</div>
{/* Activity & Volume Grid */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<TransactionVolumeChart
data={transactionVolume}
title="Volume Transaksi"
description={getFilterDescription(
'Total jumlah pesanan yang diproses per bulan.',
)}
/>
<AovTrendChart
data={transactionVolume}
title="Tren AOV"
description={getFilterDescription(
'Rata-rata nilai belanja per pesanan pelanggan setiap bulannya.',
)}
/>
<KnobChart
title="Repeat Order"
description={getFilterDescription(
'Persentase pelanggan yang kembali berbelanja.',
)}
percentage={stats.repeat_order_percentage.value}
label="Repeat Order"
footerText={`${stats.repeat_customers.value} dari ${stats.total_customers.value} pelanggan melakukan repeat order.`}
/>
</div>
<SalesByHourChart
data={salesByHour}
title="Jam Sibuk"
description={getFilterDescription(
'Grafik aktivitas transaksi berdasarkan waktu (jam) dalam sehari.',
)}
config={{
total: {
label: 'Total Penjualan',
color: 'var(--chart-1)',
},
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 @4xl/main:grid-cols-4">
<CustomPieChart
title="Metode Pembayaran"
description={getFilterDescription(
'Distribusi transaksi berdasarkan metode pembayaran yang digunakan.',
)}
data={paymentMethods}
colorOffset={0}
/>
<CustomPieChart
title="Status Pesanan"
description={getFilterDescription(
'Status terkini dari seluruh pesanan yang masuk.',
)}
data={orderStatuses}
colorOffset={120}
/>
<CustomPieChart
title="Channel Pesanan"
description={getFilterDescription(
'Sumber pesanan berdasarkan platform atau saluran penjualan.',
)}
data={orderChannels}
colorOffset={240}
/>
<CustomPieChart
title="Top 5 Kategori"
description={getFilterDescription(
'Kategori produk yang paling banyak menyumbang penjualan.',
)}
data={topCategories}
colorOffset={60}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CustomBarChart
title="Top 5 Produk"
description={getFilterDescription(
'Daftar produk dengan volume penjualan tertinggi.',
)}
data={topProducts}
colorOffset={45}
/>
<CustomBarChart
title="Top 5 Pelanggan"
description={getFilterDescription(
'Pelanggan dengan total nominal belanja tertinggi (setia).',
)}
data={topCustomers}
colorOffset={180}
isCurrency={true}
/>
</div>
</div>
</>
);
}
Analisa.layout = {
breadcrumbs: [
{
title: 'Analisa',
href: analysis().url,
},
],
};