- Added methods to AnalysisService for retrieving monthly revenue by channel and revenue by payment type. - Updated AnalysisController to include new metrics in the index response. - Enhanced Analysis.vue to visualize revenue data by channel and payment type with appropriate charts and tooltips.
1359 lines
56 KiB
Vue
1359 lines
56 KiB
Vue
<script setup lang="ts">
|
|
import { Head, router } from '@inertiajs/vue3';
|
|
import {
|
|
Banknote,
|
|
Package,
|
|
ShoppingCart,
|
|
TrendingUp,
|
|
UserCheck,
|
|
} from '@lucide/vue';
|
|
import { Donut, GroupedBar } from '@unovis/ts';
|
|
import {
|
|
VisAxis,
|
|
VisDonut,
|
|
VisGroupedBar,
|
|
VisSingleContainer,
|
|
VisTooltip,
|
|
VisXYContainer,
|
|
} from '@unovis/vue';
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import StatCard from '@/components/card/StatCard.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from '@/components/ui/card';
|
|
import type { ChartConfig } from '@/components/ui/chart';
|
|
import { ChartContainer } from '@/components/ui/chart';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useCan } from '@/composables/useCan';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
|
|
import admin from '@/routes/admin';
|
|
|
|
const { can, hasRole, hasAnyRole } = useCan();
|
|
|
|
const props = defineProps<{
|
|
filters: {
|
|
start_date?: string;
|
|
end_date?: string;
|
|
};
|
|
attendance: {
|
|
total_employees: number;
|
|
percentage: number;
|
|
present: number;
|
|
absent: number;
|
|
on_leave: number;
|
|
};
|
|
myAttendance: {
|
|
total_days: number;
|
|
present_days: number;
|
|
absent_days: number;
|
|
leave_days: number;
|
|
percentage: number;
|
|
} | null;
|
|
isManager: boolean;
|
|
cashOverview: {
|
|
total_balance: number;
|
|
total_transactions: number;
|
|
total_deposit: number;
|
|
total_withdrawal: number;
|
|
};
|
|
rawMaterialStock: {
|
|
total_stock: number;
|
|
total_value: number;
|
|
by_unit: {
|
|
yard: number;
|
|
meter: number;
|
|
kilogram: number;
|
|
};
|
|
};
|
|
productStock: {
|
|
total_stock: number;
|
|
total_reject: number;
|
|
total_retail: number;
|
|
total_value: number;
|
|
total_products: number;
|
|
total_variants: number;
|
|
total_categories: number;
|
|
};
|
|
revenueSummary: {
|
|
total_revenue: number;
|
|
total_discount: number;
|
|
total_marketplace_fees: number;
|
|
total_deduction: number;
|
|
total_orders: number;
|
|
avg_order: number;
|
|
};
|
|
monthlyRevenue: Array<{
|
|
month: string;
|
|
total: number;
|
|
net: number;
|
|
net_warehouse: number;
|
|
net_retail: number;
|
|
deduction: number;
|
|
}>;
|
|
monthlyRevenueByChannel: Array<{
|
|
month: string;
|
|
store: number;
|
|
shopee: number;
|
|
tiktok: number;
|
|
}>;
|
|
revenueByPaymentType: Array<{
|
|
payment_type: string;
|
|
label: string;
|
|
total: number;
|
|
}>;
|
|
expenseSummary: {
|
|
total: number;
|
|
purchase_total: number;
|
|
expense_total: number;
|
|
advance_total: number;
|
|
};
|
|
monthlyExpense: Array<{
|
|
month: string;
|
|
total: number;
|
|
purchase: number;
|
|
expense: number;
|
|
advance: number;
|
|
}>;
|
|
busyHours: Array<{
|
|
hour: string;
|
|
orders: number;
|
|
}>;
|
|
profitMetrics: {
|
|
total_orders: number;
|
|
total_products_sold: number;
|
|
hpp: number;
|
|
gross_profit: number;
|
|
net_profit: number;
|
|
profit_margin: number;
|
|
aov: number;
|
|
items_per_transaction: number;
|
|
};
|
|
topSuppliers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
purchase_count: number;
|
|
}>;
|
|
topCustomers: Array<{
|
|
name: string;
|
|
total_amount: number;
|
|
order_count: number;
|
|
}>;
|
|
topProducts: Array<{
|
|
name: string;
|
|
total_qty: number;
|
|
total_revenue: number;
|
|
}>;
|
|
marketingSales: Array<{
|
|
marketing_name: string;
|
|
total_orders: number;
|
|
total_products_sold: number;
|
|
total_revenue: number;
|
|
total_subtotal: number;
|
|
total_discount: number;
|
|
avg_order: number;
|
|
}>;
|
|
}>();
|
|
|
|
const startDate = ref(props.filters.start_date ?? '');
|
|
const endDate = ref(props.filters.end_date ?? '');
|
|
const selectedPreset = ref<string>('');
|
|
|
|
const hasActiveFilters = computed(() => !!startDate.value || !!endDate.value);
|
|
|
|
function formatDate(date: Date): string {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function onPresetChange(value: any) {
|
|
if (typeof value !== 'string') {
|
|
return;
|
|
}
|
|
|
|
selectedPreset.value = value;
|
|
const now = new Date();
|
|
let start: Date;
|
|
|
|
switch (value) {
|
|
case 'today':
|
|
start = new Date(now);
|
|
break;
|
|
case 'week':
|
|
start = new Date(now);
|
|
start.setDate(now.getDate() - now.getDay() + 1);
|
|
break;
|
|
case 'month':
|
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
break;
|
|
case 'year':
|
|
start = new Date(now.getFullYear(), 0, 1);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
startDate.value = formatDate(start);
|
|
endDate.value = formatDate(now);
|
|
}
|
|
|
|
// Revenue Chart
|
|
type MonthlyRevenueData = {
|
|
month: string;
|
|
total: number;
|
|
net: number;
|
|
net_warehouse: number;
|
|
net_retail: number;
|
|
deduction: number;
|
|
};
|
|
|
|
const revenueChartConfig = {
|
|
total: {
|
|
label: 'Total',
|
|
color: '#60a5fa',
|
|
},
|
|
net: {
|
|
label: 'Bersih',
|
|
color: '#22c55e',
|
|
},
|
|
net_warehouse: {
|
|
label: 'Total Gudang',
|
|
color: '#10b981',
|
|
},
|
|
net_retail: {
|
|
label: 'Total Ecer',
|
|
color: '#06b6d4',
|
|
},
|
|
deduction: {
|
|
label: 'Potongan',
|
|
color: '#f97316',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const revenueTotals = computed(() => {
|
|
const net_warehouse = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_warehouse ?? 0), 0);
|
|
const net_retail = props.monthlyRevenue.reduce((sum, item) => sum + (item.net_retail ?? 0), 0);
|
|
|
|
return {
|
|
total: props.revenueSummary.total_revenue,
|
|
net:
|
|
props.revenueSummary.total_revenue -
|
|
props.revenueSummary.total_deduction -
|
|
props.profitMetrics.hpp,
|
|
net_warehouse,
|
|
net_retail,
|
|
deduction: props.revenueSummary.total_deduction,
|
|
};
|
|
});
|
|
|
|
const visibleRevenueCharts = computed(() => {
|
|
const isOwner = hasAnyRole(['owner', 'developer']);
|
|
const isCashier = hasRole('cashier');
|
|
|
|
if (isOwner) {
|
|
return ['total', 'net', 'net_warehouse', 'net_retail', 'deduction'] as const;
|
|
}
|
|
|
|
if (isCashier) {
|
|
return ['total', 'deduction'] as const;
|
|
}
|
|
|
|
return ['total', 'net', 'deduction'] as const;
|
|
});
|
|
|
|
const revenueYAccessors = computed(() => {
|
|
return visibleRevenueCharts.value.map((chart) => (d: MonthlyRevenueData) => d[chart]);
|
|
});
|
|
|
|
const revenueColors = computed(() => {
|
|
return visibleRevenueCharts.value.map((chart) => revenueChartConfig[chart].color);
|
|
});
|
|
|
|
// Revenue By Channel Chart
|
|
const channelChartColors: Record<string, string> = {
|
|
store: '#22c55e',
|
|
shopee: '#ee4d2d',
|
|
tiktok: '#000000',
|
|
};
|
|
|
|
const channelChartLabels: Record<string, string> = {
|
|
store: 'Toko',
|
|
shopee: 'Shopee',
|
|
tiktok: 'TikTok',
|
|
};
|
|
|
|
const revenueByChannelData = computed(() => {
|
|
const totals = props.monthlyRevenueByChannel.reduce(
|
|
(acc, item) => {
|
|
acc.store += item.store ?? 0;
|
|
acc.shopee += item.shopee ?? 0;
|
|
acc.tiktok += item.tiktok ?? 0;
|
|
|
|
return acc;
|
|
},
|
|
{ store: 0, shopee: 0, tiktok: 0 },
|
|
);
|
|
|
|
return [
|
|
{ channel: 'store', label: channelChartLabels.store, total: totals.store },
|
|
{ channel: 'shopee', label: channelChartLabels.shopee, total: totals.shopee },
|
|
{ channel: 'tiktok', label: channelChartLabels.tiktok, total: totals.tiktok },
|
|
];
|
|
});
|
|
|
|
const revenueByChannelChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
revenueByChannelData.value.forEach((item) => {
|
|
config[item.channel] = {
|
|
label: item.label,
|
|
color: channelChartColors[item.channel],
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
const channelSelector = Donut.selectors.segment;
|
|
|
|
const channelRevenueTotal = computed(() => {
|
|
return revenueByChannelData.value.reduce((sum, item) => sum + (item.total ?? 0), 0);
|
|
});
|
|
|
|
function revenueByChannelTooltip(arc: any) {
|
|
const d = arc.data as typeof revenueByChannelData.value[number];
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${channelChartColors[d.channel] ?? '#94a3b8'}"></span>
|
|
<span class="text-muted-foreground">${d.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(d.total)}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
// Revenue By Payment Type Chart
|
|
const paymentTypeColors: Record<string, string> = {
|
|
cash: '#22c55e',
|
|
transfer: '#60a5fa',
|
|
qris: '#a855f7',
|
|
marketplace: '#f97316',
|
|
};
|
|
|
|
const paymentTypeChartConfig = computed(() => {
|
|
const config: ChartConfig = {};
|
|
props.revenueByPaymentType.forEach((item) => {
|
|
config[item.payment_type] = {
|
|
label: item.label,
|
|
color: paymentTypeColors[item.payment_type] ?? '#94a3b8',
|
|
};
|
|
});
|
|
|
|
return config;
|
|
});
|
|
|
|
const paymentTypeSelector = Donut.selectors.segment;
|
|
|
|
const paymentTypeTotal = computed(() => {
|
|
return props.revenueByPaymentType.reduce((sum, item) => sum + (item.total ?? 0), 0);
|
|
});
|
|
|
|
function paymentTypeTooltip(arc: any) {
|
|
const d = arc.data as typeof props.revenueByPaymentType[number];
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${paymentTypeColors[d.payment_type] ?? '#94a3b8'}"></span>
|
|
<span class="text-muted-foreground">${d.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(d.total)}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
// Expense Chart
|
|
type MonthlyExpenseData = {
|
|
month: string;
|
|
total: number;
|
|
purchase: number;
|
|
expense: number;
|
|
advance: number;
|
|
};
|
|
|
|
const expenseChartConfig = {
|
|
total: {
|
|
label: 'Total',
|
|
color: '#60a5fa',
|
|
},
|
|
purchase: {
|
|
label: 'Belanja',
|
|
color: '#f97316',
|
|
},
|
|
expense: {
|
|
label: 'Pengeluaran',
|
|
color: '#a855f7',
|
|
},
|
|
advance: {
|
|
label: 'Kasbon',
|
|
color: '#ef4444',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const expenseTotals = computed(() => ({
|
|
total: props.expenseSummary.total,
|
|
purchase: props.expenseSummary.purchase_total,
|
|
expense: props.expenseSummary.expense_total,
|
|
advance: props.expenseSummary.advance_total,
|
|
}));
|
|
|
|
const visibleExpenseCharts = computed(() => {
|
|
const charts = ['total', 'purchase', 'expense', 'advance'] as const;
|
|
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return charts;
|
|
}
|
|
|
|
return charts.filter((c) => c !== 'purchase');
|
|
});
|
|
|
|
const expenseYAccessors = computed(() => {
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return [
|
|
(d: MonthlyExpenseData) => d.total,
|
|
(d: MonthlyExpenseData) => d.purchase,
|
|
(d: MonthlyExpenseData) => d.expense,
|
|
(d: MonthlyExpenseData) => d.advance,
|
|
];
|
|
}
|
|
|
|
return [
|
|
(d: MonthlyExpenseData) => d.total,
|
|
(d: MonthlyExpenseData) => d.expense,
|
|
(d: MonthlyExpenseData) => d.advance,
|
|
];
|
|
});
|
|
|
|
const expenseColors = computed(() => {
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return [
|
|
expenseChartConfig.total.color,
|
|
expenseChartConfig.purchase.color,
|
|
expenseChartConfig.expense.color,
|
|
expenseChartConfig.advance.color,
|
|
];
|
|
}
|
|
|
|
return [
|
|
expenseChartConfig.total.color,
|
|
expenseChartConfig.expense.color,
|
|
expenseChartConfig.advance.color,
|
|
];
|
|
});
|
|
|
|
// Busy Hours Chart
|
|
type BusyHourData = { hour: string; orders: number };
|
|
|
|
const busyHoursChartConfig = {
|
|
orders: {
|
|
label: 'Pesanan',
|
|
color: '#60a5fa',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const peakHour = computed(() => {
|
|
if (props.busyHours.length === 0) {
|
|
return { hour: '-', orders: 0 };
|
|
}
|
|
|
|
return props.busyHours.reduce(
|
|
(max, item) => (item.orders > max.orders ? item : max),
|
|
props.busyHours[0],
|
|
);
|
|
});
|
|
|
|
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
|
|
|
onMounted(() => {
|
|
const onResize = () => {
|
|
windowWidth.value = window.innerWidth;
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
onBeforeUnmount(() => window.removeEventListener('resize', onResize));
|
|
});
|
|
|
|
const busyHourTickValues = computed(() => {
|
|
const all = props.busyHours.map((_, i) => i);
|
|
|
|
if (windowWidth.value < 640) {
|
|
return all.filter((_, i) => i % 4 === 0);
|
|
}
|
|
|
|
if (windowWidth.value < 1024) {
|
|
return all.filter((_, i) => i % 2 === 0);
|
|
}
|
|
|
|
return all;
|
|
});
|
|
|
|
const sectionOrder = computed(() => {
|
|
if (hasAnyRole(['owner', 'developer'])) {
|
|
return {
|
|
statCards: 1,
|
|
revenue: 5,
|
|
revenueByChannel: 6,
|
|
expense: 7,
|
|
profitGross: 8,
|
|
totalOrder: 9,
|
|
marketingSales: 10,
|
|
topSuppliers: 11,
|
|
topProducts: 12,
|
|
topCustomers: 13,
|
|
busyHours: 14,
|
|
};
|
|
}
|
|
|
|
if (hasAnyRole(['admin_toko', 'direktur'])) {
|
|
return {
|
|
statCards: 1,
|
|
revenue: 4,
|
|
revenueByChannel: 5,
|
|
expense: 6,
|
|
profitGross: 7,
|
|
totalOrder: 8,
|
|
marketingSales: 9,
|
|
topProducts: 10,
|
|
topCustomers: 11,
|
|
busyHours: 12,
|
|
};
|
|
}
|
|
|
|
if (hasRole('marketing')) {
|
|
return {
|
|
statCards: 1,
|
|
revenue: 2,
|
|
revenueByChannel: 3,
|
|
totalOrder: 4,
|
|
topProducts: 5,
|
|
topCustomers: 6,
|
|
};
|
|
}
|
|
|
|
return {};
|
|
});
|
|
|
|
// Top 5 Chart configs
|
|
type SupplierData = { name: string; amount: number };
|
|
type CustomerData = { name: string; amount: number };
|
|
type ProductData = { name: string; qty: number };
|
|
|
|
const supplierChartConfig = {
|
|
amount: {
|
|
label: 'Total Pembelian',
|
|
color: '#60a5fa',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const customerChartConfig = {
|
|
amount: {
|
|
label: 'Total Pesanan',
|
|
color: '#22c55e',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
const productChartConfig = {
|
|
qty: {
|
|
label: 'Jumlah Terjual',
|
|
color: '#a855f7',
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
// Tooltip trigger for bar charts
|
|
const barSelector = GroupedBar.selectors.bar;
|
|
|
|
function revenueTooltip(d: any) {
|
|
const item = d as MonthlyRevenueData;
|
|
const isOwner = hasAnyRole(['owner', 'developer']);
|
|
const isCashier = hasRole('cashier');
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
|
<p style="margin:0;font-weight:500">${item.month}</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.total.color}"></span>
|
|
<span class="text-muted-foreground">${revenueChartConfig.total.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.total)}</span>
|
|
</p>
|
|
${!isCashier ? `
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net.color}"></span>
|
|
<span class="text-muted-foreground">${revenueChartConfig.net.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net)}</span>
|
|
</p>
|
|
` : ''}
|
|
${isOwner ? `
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_warehouse.color}"></span>
|
|
<span class="text-muted-foreground">${revenueChartConfig.net_warehouse.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_warehouse)}</span>
|
|
</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.net_retail.color}"></span>
|
|
<span class="text-muted-foreground">${revenueChartConfig.net_retail.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.net_retail)}</span>
|
|
</p>
|
|
` : ''}
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${revenueChartConfig.deduction.color}"></span>
|
|
<span class="text-muted-foreground">${revenueChartConfig.deduction.label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.deduction)}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function expenseTooltip(d: any) {
|
|
const item = d as MonthlyExpenseData;
|
|
const charts = visibleExpenseCharts.value;
|
|
const rows = charts
|
|
.map(
|
|
(chart) =>
|
|
`<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${expenseChartConfig[chart].color}"></span>
|
|
<span class="text-muted-foreground">${expenseChartConfig[chart].label}</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item[chart])}</span>
|
|
</p>`,
|
|
)
|
|
.join('');
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.6">
|
|
<p style="margin:0;font-weight:500">${item.month}</p>
|
|
${rows}
|
|
</div>`;
|
|
}
|
|
|
|
function busyHoursTooltip(d: any) {
|
|
const item = d as BusyHourData;
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;font-weight:500">${item.hour}</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${busyHoursChartConfig.orders.color}"></span>
|
|
<span class="text-muted-foreground">Pesanan</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${item.orders}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function supplierTooltip(d: any) {
|
|
const item = d as SupplierData;
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;font-weight:500">${item.name}</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${supplierChartConfig.amount.color}"></span>
|
|
<span class="text-muted-foreground">Total Pembelian</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.amount)}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function customerTooltip(d: any) {
|
|
const item = d as CustomerData;
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;font-weight:500">${item.name}</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${customerChartConfig.amount.color}"></span>
|
|
<span class="text-muted-foreground">Total Pesanan</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item.amount)}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
function productTooltip(d: any) {
|
|
const item = d as ProductData;
|
|
|
|
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
|
|
<p style="margin:0;font-weight:500">${item.name}</p>
|
|
<p style="margin:0;display:flex;align-items:center;gap:0.5rem">
|
|
<span class="size-2.5 rounded-full" style="background-color: ${productChartConfig.qty.color}"></span>
|
|
<span class="text-muted-foreground">Jumlah Terjual</span>
|
|
<span class="ml-auto font-medium tabular-nums text-foreground">${item.qty}</span>
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
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 productBarData = computed<ProductData[]>(() => {
|
|
return props.topProducts.map((p) => ({
|
|
name: p.name.length > 20 ? p.name.substring(0, 20) + '...' : p.name,
|
|
qty: p.total_qty,
|
|
}));
|
|
});
|
|
|
|
function applyFilters() {
|
|
router.get(
|
|
admin.analysis.url(),
|
|
{
|
|
start_date: startDate.value,
|
|
end_date: endDate.value,
|
|
},
|
|
{
|
|
preserveState: true,
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
function clearFilters() {
|
|
startDate.value = '';
|
|
endDate.value = '';
|
|
selectedPreset.value = '';
|
|
applyFilters();
|
|
}
|
|
|
|
watch([startDate, endDate], () => {
|
|
applyFilters();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
|
|
<Head title="Analisa" />
|
|
|
|
<AdminLayout>
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="space-y-1">
|
|
<h2 class="text-2xl font-bold tracking-tight">Analisa</h2>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<Select v-model="selectedPreset" @update:model-value="onPresetChange">
|
|
<SelectTrigger class="h-9 w-[140px]">
|
|
<SelectValue placeholder="Filter Cepat" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="today">Hari Ini</SelectItem>
|
|
<SelectItem value="week">Minggu Ini</SelectItem>
|
|
<SelectItem value="month">Bulan Ini</SelectItem>
|
|
<SelectItem value="year">Tahun Ini</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Mulai:</span>
|
|
<DatePicker v-model="startDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Sampai:</span>
|
|
<DatePicker v-model="endDate" class="w-[170px]" placeholder="Pilih tanggal" />
|
|
</div>
|
|
<Button v-if="hasActiveFilters" variant="ghost" size="sm" @click="clearFilters" class="h-9 px-3">
|
|
Reset Filter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stat Cards -->
|
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4" :style="{ order: sectionOrder.statCards ?? 99 }">
|
|
<StatCard v-if="can('analysis.attendance') && isManager" title="Kehadiran" :icon="UserCheck"
|
|
main-label="Total Karyawan" :main-value="attendance.total_employees"
|
|
:sub-label="attendance.percentage + '% hadir'" :items="[
|
|
{
|
|
label: 'Hadir',
|
|
value: attendance.present,
|
|
},
|
|
{
|
|
label: 'Tidak Hadir',
|
|
value: attendance.absent,
|
|
},
|
|
{
|
|
label: 'Cuti',
|
|
value: attendance.on_leave,
|
|
},
|
|
]" />
|
|
|
|
<StatCard v-if="
|
|
can('analysis.attendance') && !isManager && myAttendance
|
|
" title="Kehadiran Saya" :icon="UserCheck" main-label="Hari Kerja"
|
|
:main-value="myAttendance.total_days" :sub-label="myAttendance.percentage + '% hadir'" :items="[
|
|
{
|
|
label: 'Hadir',
|
|
value: myAttendance.present_days,
|
|
},
|
|
{
|
|
label: 'Tidak Hadir',
|
|
value: myAttendance.absent_days,
|
|
},
|
|
{
|
|
label: 'Cuti',
|
|
value: myAttendance.leave_days,
|
|
},
|
|
]" />
|
|
|
|
<StatCard v-if="can('analysis.cash')" title="Kas Toko" :icon="Banknote" main-label="Total Saldo"
|
|
:main-value="'Rp' + formatRupiah(cashOverview.total_balance)
|
|
" :sub-label="cashOverview.total_transactions + ' transaksi'" :items="[
|
|
{
|
|
label: 'Deposit',
|
|
value:
|
|
'Rp' + formatRupiah(cashOverview.total_deposit),
|
|
},
|
|
{
|
|
label: 'Withdrawal',
|
|
value:
|
|
'Rp' +
|
|
formatRupiah(cashOverview.total_withdrawal),
|
|
},
|
|
]" :cols="2" />
|
|
|
|
<StatCard v-if="can('analysis.raw_materials')" title="Bahan Baku" :icon="Package"
|
|
main-label="Total Stok" :main-value="rawMaterialStock.total_stock.toLocaleString('id-ID')
|
|
" :sub-label="'Rp' + formatRupiah(rawMaterialStock.total_value)
|
|
" :items="[
|
|
{
|
|
label: 'Yard',
|
|
value:
|
|
rawMaterialStock.by_unit?.yard?.toLocaleString(
|
|
'id-ID',
|
|
) ?? '0',
|
|
},
|
|
{
|
|
label: 'Meter',
|
|
value:
|
|
rawMaterialStock.by_unit?.meter?.toLocaleString(
|
|
'id-ID',
|
|
) ?? '0',
|
|
},
|
|
{
|
|
label: 'Kg',
|
|
value:
|
|
rawMaterialStock.by_unit?.kilogram?.toLocaleString(
|
|
'id-ID',
|
|
) ?? '0',
|
|
},
|
|
]" />
|
|
|
|
<StatCard v-if="can('analysis.product_stock')" title="Stok Produk" :icon="ShoppingCart"
|
|
main-label="Total Stok" :main-value="(
|
|
productStock.total_stock +
|
|
productStock.total_reject +
|
|
productStock.total_retail
|
|
).toLocaleString('id-ID')" :sub-label="'Rp' + formatRupiah(productStock.total_value)" :items="[
|
|
{
|
|
label: 'Stok Bagus',
|
|
value: productStock.total_stock.toLocaleString(
|
|
'id-ID',
|
|
),
|
|
},
|
|
{
|
|
label: 'Stok Reject',
|
|
value: productStock.total_reject.toLocaleString(
|
|
'id-ID',
|
|
),
|
|
},
|
|
{
|
|
label: 'Stok Ecer',
|
|
value: productStock.total_retail.toLocaleString(
|
|
'id-ID',
|
|
),
|
|
},
|
|
]" />
|
|
</div>
|
|
|
|
<!-- Revenue Chart -->
|
|
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0" :style="{ order: sectionOrder.revenue ?? 99 }">
|
|
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan</CardTitle>
|
|
</div>
|
|
<div class="flex flex-col sm:flex-row">
|
|
<div v-for="chart in visibleRevenueCharts" :key="chart"
|
|
class="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ revenueChartConfig[chart].label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(revenueTotals[chart]) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<div v-if="monthlyRevenue.length > 0">
|
|
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
|
|
<div v-for="chart in visibleRevenueCharts" :key="chart" class="flex items-center gap-1.5">
|
|
<span class="size-2.5 rounded-full" :style="{
|
|
backgroundColor:
|
|
revenueChartConfig[chart].color,
|
|
}"></span>
|
|
<span class="text-xs text-muted-foreground">{{
|
|
revenueChartConfig[chart].label
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<ChartContainer :config="revenueChartConfig" class="aspect-auto h-[300px] w-full">
|
|
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i
|
|
" :y="revenueYAccessors" :color="revenueColors" :bar-padding="0.1"
|
|
:group-padding="0.2" :rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: MonthlyRevenueData, i: number) => i
|
|
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
|
monthlyRevenue[d]?.month ?? ''
|
|
" :tick-values="monthlyRevenue.map((_, i) => i)
|
|
" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
|
|
'Rp' + formatRupiahShort(d)
|
|
" />
|
|
<VisTooltip :triggers="{
|
|
[barSelector]: revenueTooltip,
|
|
}" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
</div>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pendapatan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Revenue By Channel & Payment Type -->
|
|
<div class="grid gap-4 md:grid-cols-2" :style="{ order: sectionOrder.revenueByChannel ?? 99 }">
|
|
<!-- Revenue By Channel Chart -->
|
|
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0">
|
|
<CardHeader class="flex flex-row items-center justify-between border-b p-0!">
|
|
<div class="flex flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan per Channel</CardTitle>
|
|
</div>
|
|
<div class="flex flex-wrap">
|
|
<div v-for="item in revenueByChannelData" :key="item.channel"
|
|
class="flex flex-col justify-center gap-1 border-l px-4 py-3 text-right sm:px-6 sm:py-4">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ item.label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(item.total) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<div v-if="revenueByChannelData.length > 0">
|
|
<div class="mx-auto aspect-square max-h-[300px]">
|
|
<ChartContainer :config="revenueByChannelChartConfig" class="h-full w-full">
|
|
<VisSingleContainer :data="revenueByChannelData" :margin="{ top: 10, bottom: 10 }">
|
|
<VisDonut :value="(d: (typeof revenueByChannelData)[number]) => d.total"
|
|
:color="(d: (typeof revenueByChannelData)[number]) => channelChartColors[d.channel] ?? '#94a3b8'"
|
|
:arc-width="30" />
|
|
<VisTooltip :triggers="{ [channelSelector]: revenueByChannelTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</VisSingleContainer>
|
|
</ChartContainer>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pendapatan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Revenue By Payment Type Chart -->
|
|
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0">
|
|
<CardHeader class="flex flex-row items-center justify-between border-b p-0!">
|
|
<div class="flex flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pendapatan per Pembayaran</CardTitle>
|
|
</div>
|
|
<div class="flex flex-wrap">
|
|
<div v-for="item in revenueByPaymentType" :key="item.payment_type"
|
|
class="flex flex-col justify-center gap-1 border-l px-4 py-3 text-right sm:px-6 sm:py-4">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ item.label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(item.total) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<div v-if="revenueByPaymentType.length > 0">
|
|
<div class="mx-auto aspect-square max-h-[300px]">
|
|
<ChartContainer :config="paymentTypeChartConfig" class="h-full w-full">
|
|
<VisSingleContainer :data="revenueByPaymentType" :margin="{ top: 10, bottom: 10 }">
|
|
<VisDonut :value="(d: (typeof revenueByPaymentType)[number]) => d.total"
|
|
:color="(d: (typeof revenueByPaymentType)[number]) => paymentTypeColors[d.payment_type] ?? '#94a3b8'"
|
|
:arc-width="30" />
|
|
<VisTooltip :triggers="{ [paymentTypeSelector]: paymentTypeTooltip }"
|
|
class-name="custom-tooltip" />
|
|
</VisSingleContainer>
|
|
</ChartContainer>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pendapatan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Expense Chart -->
|
|
<Card v-if="can('analysis.expense')" class="py-4 sm:py-0" :style="{ order: sectionOrder.expense ?? 99 }">
|
|
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
|
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
|
<CardTitle>Pengeluaran</CardTitle>
|
|
</div>
|
|
<div class="flex flex-col sm:flex-row">
|
|
<div v-for="chart in visibleExpenseCharts" :key="chart"
|
|
class="flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ expenseChartConfig[chart].label }}
|
|
</span>
|
|
<span class="text-sm">
|
|
Rp{{ formatRupiah(expenseTotals[chart]) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="px-2 sm:p-6">
|
|
<div v-if="monthlyExpense.length > 0">
|
|
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
|
|
<div v-for="chart in visibleExpenseCharts" :key="chart" class="flex items-center gap-1.5">
|
|
<span class="size-2.5 rounded-full" :style="{
|
|
backgroundColor:
|
|
expenseChartConfig[chart].color,
|
|
}"></span>
|
|
<span class="text-xs text-muted-foreground">{{
|
|
expenseChartConfig[chart].label
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<ChartContainer :config="expenseChartConfig" class="aspect-auto h-[300px] w-full">
|
|
<VisXYContainer :data="monthlyExpense" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: MonthlyExpenseData, i: number) => i
|
|
" :y="expenseYAccessors" :color="expenseColors" :bar-padding="0.1"
|
|
:group-padding="0.2" :rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: MonthlyExpenseData, i: number) => i
|
|
" :tick-line="false" :domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
|
monthlyExpense[d]?.month ?? ''
|
|
" :tick-values="monthlyExpense.map((_, i) => i)
|
|
" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
|
|
'Rp' + formatRupiahShort(d)
|
|
" />
|
|
<VisTooltip :triggers="{
|
|
[barSelector]: expenseTooltip,
|
|
}" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
</div>
|
|
<div v-else class="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pengeluaran
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Laba Kotor -->
|
|
<StatCard v-if="
|
|
can('analysis.profit_gross') ||
|
|
can('analysis.profit_hpp')
|
|
" title="Laba Kotor" :icon="TrendingUp" main-label="Laba Kotor" :main-value="'Rp' + formatRupiah(profitMetrics.gross_profit)
|
|
" :items="[
|
|
{
|
|
label: 'Pendapatan',
|
|
value:
|
|
'Rp' +
|
|
formatRupiah(revenueSummary.total_revenue),
|
|
},
|
|
{
|
|
label: 'HPP',
|
|
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
|
},
|
|
...(!hasRole('cashier') ? [
|
|
{
|
|
label: 'Laba Bersih',
|
|
value:
|
|
'Rp' +
|
|
formatRupiah(profitMetrics.net_profit) +
|
|
' (' +
|
|
profitMetrics.profit_margin +
|
|
'%)',
|
|
},
|
|
] : []),
|
|
]" :style="{ order: sectionOrder.profitGross ?? 99 }" />
|
|
|
|
<!-- Total Order -->
|
|
<StatCard v-if="can('analysis.profit_orders')" title="Total Order" :icon="ShoppingCart"
|
|
main-label="Pesanan Selesai" :main-value="profitMetrics.total_orders.toLocaleString('id-ID')
|
|
" :items="[
|
|
{
|
|
label: 'Produk Terjual',
|
|
value: profitMetrics.total_products_sold.toLocaleString(
|
|
'id-ID',
|
|
),
|
|
},
|
|
{
|
|
label: 'Item/Transaksi',
|
|
value: profitMetrics.items_per_transaction,
|
|
},
|
|
{
|
|
label: 'Rata-rata/Transaksi',
|
|
value: 'Rp' + formatRupiah(profitMetrics.aov),
|
|
},
|
|
]" :style="{ order: sectionOrder.totalOrder ?? 99 }" />
|
|
|
|
<!-- Marketing Sales Table -->
|
|
<Card v-if="can('analysis.marketing_sales')" :style="{ order: sectionOrder.marketingSales ?? 99 }">
|
|
<CardHeader>
|
|
<CardTitle>Penjualan Marketing</CardTitle>
|
|
<CardDescription>Rekap penjualan per marketing</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="marketingSales.length > 0" class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b">
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
Marketing
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Total Order
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Produk Terjual
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Total Pendapatan
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Total Subtotal
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Total Diskon
|
|
</th>
|
|
<th class="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
Rata-rata Order
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(item, index) in marketingSales" :key="index" class="border-b last:border-0">
|
|
<td class="px-4 py-3 font-medium">
|
|
{{ item.marketing_name }}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
{{
|
|
item.total_orders.toLocaleString(
|
|
'id-ID',
|
|
)
|
|
}}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
{{
|
|
item.total_products_sold.toLocaleString(
|
|
'id-ID',
|
|
)
|
|
}}
|
|
pcs
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
Rp{{ formatRupiah(item.total_revenue) }}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
Rp{{
|
|
formatRupiah(item.total_subtotal)
|
|
}}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
Rp{{
|
|
formatRupiah(item.total_discount)
|
|
}}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums">
|
|
Rp{{ formatRupiah(item.avg_order) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-else class="flex h-[150px] items-center justify-center text-muted-foreground">
|
|
Belum ada data penjualan marketing
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Top 5 Supplier -->
|
|
<Card v-if="can('analysis.top_suppliers')" :style="{ order: sectionOrder.topSuppliers ?? 99 }">
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Supplier</CardTitle>
|
|
<CardDescription>Berdasarkan total harga pembelian</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="supplierBarData.length > 0" :config="supplierChartConfig"
|
|
class="aspect-auto h-[250px] w-full">
|
|
<VisXYContainer :data="supplierBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: SupplierData, i: number) => i" :y="(d: SupplierData) => d.amount"
|
|
:color="supplierChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: SupplierData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
|
supplierBarData[d]?.name ?? ''
|
|
" :tick-values="supplierBarData.map((_, i) => i)
|
|
" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
|
|
'Rp' + formatRupiahShort(d)
|
|
" />
|
|
<VisTooltip :triggers="{
|
|
[barSelector]: supplierTooltip,
|
|
}" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data supplier
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Top 5 Produk -->
|
|
<Card v-if="can('analysis.top_products')" :style="{ order: sectionOrder.topProducts ?? 99 }">
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Produk</CardTitle>
|
|
<CardDescription>Berdasarkan jumlah terjual</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="productBarData.length > 0" :config="productChartConfig"
|
|
class="aspect-auto h-[250px] w-full">
|
|
<VisXYContainer :data="productBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: ProductData, i: number) => i" :y="(d: ProductData) => d.qty"
|
|
:color="productChartConfig.qty.color" :rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: ProductData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
|
productBarData[d]?.name ?? ''
|
|
" :tick-values="productBarData.map((_, i) => i)
|
|
" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" />
|
|
<VisTooltip :triggers="{
|
|
[barSelector]: productTooltip,
|
|
}" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data produk
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Top 5 Pelanggan -->
|
|
<Card v-if="can('analysis.top_customers')" :style="{ order: sectionOrder.topCustomers ?? 99 }">
|
|
<CardHeader>
|
|
<CardTitle class="text-base">Top 5 Pelanggan</CardTitle>
|
|
<CardDescription>Berdasarkan total nilai pesanan</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="customerBarData.length > 0" :config="customerChartConfig"
|
|
class="aspect-auto h-[250px] w-full">
|
|
<VisXYContainer :data="customerBarData" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: CustomerData, i: number) => i" :y="(d: CustomerData) => d.amount"
|
|
:color="customerChartConfig.amount.color" :rounded-corners="4" bar-padding="0.1" />
|
|
<VisAxis type="x" :x="(_d: CustomerData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false" :tick-format="(d: number) =>
|
|
customerBarData[d]?.name ?? ''
|
|
" :tick-values="customerBarData.map((_, i) => i)
|
|
" />
|
|
<VisAxis type="y" :num-ticks="3" :tick-line="false" :domain-line="false" :tick-format="(d: number) =>
|
|
'Rp' + formatRupiahShort(d)
|
|
" />
|
|
<VisTooltip :triggers="{
|
|
[barSelector]: customerTooltip,
|
|
}" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pelanggan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Busy Hours Chart -->
|
|
<Card v-if="can('analysis.busy_hours')" :style="{ order: sectionOrder.busyHours ?? 99 }">
|
|
<CardHeader>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Jam Sibuk Toko</CardTitle>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-sm text-muted-foreground">
|
|
Jam Tersibuk
|
|
</p>
|
|
<p class="text-2xl font-bold text-primary">
|
|
{{ peakHour.hour }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ peakHour.orders }} pesanan
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer v-if="busyHours.length > 0" :config="busyHoursChartConfig"
|
|
class="aspect-auto h-[250px] w-full">
|
|
<VisXYContainer :data="busyHours" :y-domain="[0, undefined]">
|
|
<VisGroupedBar :x="(_d: BusyHourData, i: number) => i" :y="(d: BusyHourData) => d.orders"
|
|
:color="busyHoursChartConfig.orders.color" :bar-padding="0.1" :rounded-corners="4" />
|
|
<VisAxis type="x" :x="(_d: BusyHourData, i: number) => i" :tick-line="false"
|
|
:domain-line="false" :grid-line="false" :tick-format="(d: number) => busyHours[d]?.hour ?? ''
|
|
" :tick-values="busyHourTickValues" />
|
|
<VisAxis type="y" :num-ticks="4" :tick-line="false" :domain-line="false" :tick-format="(d: number) => Math.round(d).toString()
|
|
" />
|
|
<VisTooltip :triggers="{ [barSelector]: busyHoursTooltip }" class-name="custom-tooltip" />
|
|
</VisXYContainer>
|
|
</ChartContainer>
|
|
<div v-else class="flex h-[250px] items-center justify-center text-muted-foreground">
|
|
Belum ada data pesanan
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</AdminLayout>
|
|
</template>
|
|
|
|
<style>
|
|
:root {
|
|
--vis-tooltip-background-color: transparent !important;
|
|
--vis-tooltip-border-color: transparent !important;
|
|
--vis-tooltip-padding: 0 !important;
|
|
--vis-tooltip-box-shadow: none !important;
|
|
--vis-tooltip-border-radius: 0 !important;
|
|
}
|
|
|
|
.custom-tooltip {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
padding: 0 !important;
|
|
box-shadow: none !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
</style>
|