443 lines
18 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
};
|