feat: add revenue trend analysis feature with chart visualization in Analysis component

This commit is contained in:
Yoga Pangestu 2026-08-09 16:45:53 +07:00
parent 485ae8e5ce
commit 27beb38e55
5 changed files with 119 additions and 225 deletions

View File

@ -43,6 +43,7 @@ public function index(Request $request): Response
$topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
$orderStats = $this->service->getOrderStats($startDate, $endDate);
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate);
return Inertia::render('admin/analysis/index', [
'filters' => [
@ -68,6 +69,7 @@ public function index(Request $request): Response
'topProducts' => $topProducts,
'marketingSales' => $marketingSales,
'orderStats' => $orderStats,
'revenueTrend' => $revenueTrend,
]);
}
}

View File

@ -465,6 +465,25 @@ public function getTopProducts(?string $startDate, ?string $endDate): array
->toArray();
}
public function getRevenueTrend(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
return (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw("DATE(orders.created_at) as date")
->selectRaw('SUM(order_items.quantity) as qty')
->groupBy(DB::raw("DATE(orders.created_at)"))
->orderBy(DB::raw("DATE(orders.created_at)"))
->get()
->map(fn ($item) => [
'date' => $item->date,
'qty' => (int) $item->qty,
])
->toArray();
}
public function getMarketingSales(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)

221
chart.tsx
View File

@ -1,221 +0,0 @@
"use client"
import * as React from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
export const description = "An interactive bar chart"
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
const chartConfig = {
views: {
label: "Page Views",
},
desktop: {
label: "Desktop",
color: "var(--chart-2)",
},
mobile: {
label: "Mobile",
color: "var(--chart-1)",
},
} satisfies ChartConfig
export function ChartBarInteractive() {
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("desktop")
const total = React.useMemo(
() => ({
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
}),
[]
)
return (
<Card className="py-0">
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
<CardTitle>Bar Chart - Interactive</CardTitle>
<CardDescription>
Showing total visitors for the last 3 months
</CardDescription>
</div>
<div className="flex">
{["desktop", "mobile"].map((key) => {
const chart = key as keyof typeof chartConfig
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg leading-none font-bold sm:text-3xl">
{total[key as keyof typeof total].toLocaleString()}
</span>
</button>
)
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<BarChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}}
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@ -15,7 +15,7 @@ public function run(): void
$permissions = [
'dashboard' => ['view', 'attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status', 'cash'],
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'],
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'revenue_trend', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'],
'employees' => ['view', 'create', 'update', 'delete', 'toggle_status', 'reset_password'],
'attendances' => ['view', 'create', 'delete', 'manage'],
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
@ -82,6 +82,7 @@ public function run(): void
'analysis.cash',
'analysis.product_stock',
'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense',
'analysis.busy_hours',
'analysis.profit_orders',
@ -368,6 +369,7 @@ public function run(): void
'analysis.cash',
'analysis.product_stock',
'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense',
'analysis.busy_hours',
'analysis.profit_orders',

View File

@ -26,6 +26,8 @@ import {
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
@ -182,6 +184,10 @@ type AnalysisProps = {
count: number;
}>;
};
revenueTrend: Array<{
date: string;
qty: number;
}>;
};
const revenueChartConfig = {
@ -230,6 +236,15 @@ const expenseChartConfig = {
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const;
const revenueTrendChartConfig = {
qty: {
label: 'Qty',
color: 'var(--chart-1)',
},
} satisfies ChartConfig;
const revenueTrendKeys = ['qty'] as const;
const CHANNEL_COLORS: Record<string, string> = {
store: '#22c55e',
shopee: '#ee4d2d',
@ -400,6 +415,7 @@ export default function Analysis({
topProducts,
marketingSales,
orderStats,
revenueTrend,
}: AnalysisProps) {
const { can, hasAnyRole, hasRole } = useCan();
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
@ -495,15 +511,15 @@ export default function Analysis({
const sectionOrder = useMemo(() => {
if (hasAnyRole(['owner', 'developer'])) {
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topSuppliers: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
}
if (hasAnyRole(['admin_toko', 'direktur'])) {
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 };
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
}
if (hasRole('marketing')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
}
return {};
@ -889,6 +905,82 @@ export default function Analysis({
</div>
)}
{can('analysis.revenue_trend') && (
<Card className="py-0" style={{ order: sectionOrder.revenueTrend ?? 99 }}>
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
<CardTitle>Trend Pendapatan</CardTitle>
</div>
<div className="flex flex-col sm:flex-row">
{revenueTrendKeys.map((key) => {
const total = revenueTrend.reduce((acc, item) => acc + (item[key] ?? 0), 0);
return (
<button
key={key}
data-active={false}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
>
<span className="text-[10px] text-muted-foreground">
{revenueTrendChartConfig[key].label}
</span>
<span className="text-xs leading-none font-semibold sm:text-sm">
{total.toLocaleString('id-ID')}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{revenueTrend.length > 0 ? (
<ChartContainer config={revenueTrendChartConfig} className="aspect-auto h-[250px] w-full">
<LineChart data={revenueTrend} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
}}
formatter={(value, name, item, index) => (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
/>
<span className="text-muted-foreground">{revenueTrendChartConfig[item.dataKey as keyof typeof revenueTrendChartConfig]?.label ?? name}</span>
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
{Number(value).toLocaleString('id-ID')}
</span>
</>
)}
/>
}
/>
<Line dataKey="qty" type="monotone" stroke="var(--color-qty)" strokeWidth={2} dot={false} />
</LineChart>
</ChartContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data trend pendapatan</div>
)}
</CardContent>
</Card>
)}
{can('analysis.marketing_sales') && (
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
<CardHeader>