feat: add formatRupiahShort function for concise currency formatting and update Analysis.vue to utilize new formatting

This commit is contained in:
Yoga Pangestu 2026-07-01 19:28:06 +07:00
parent 6e75debff0
commit fbd5bd7b6d
2 changed files with 145 additions and 60 deletions

View File

@ -11,3 +11,28 @@ export function formatRupiah(value: string | number): string {
return Number(digits).toLocaleString('id-ID');
}
export function formatRupiahShort(value: number): string {
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 1_000_000_000) {
const v = abs / 1_000_000_000;
return sign + (v % 1 === 0 ? v.toFixed(0) : v.toFixed(1).replace('.', ',')) + 'M';
}
if (abs >= 1_000_000) {
const v = abs / 1_000_000;
return sign + (v % 1 === 0 ? v.toFixed(0) : v.toFixed(1).replace('.', ',')) + 'jt';
}
if (abs >= 1_000) {
const v = abs / 1_000;
return sign + (v % 1 === 0 ? v.toFixed(0) : v.toFixed(1).replace('.', ',')) + 'rb';
}
return sign + abs.toString();
}

View File

@ -19,7 +19,7 @@ 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 } from '@/lib/rupiah';
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
import admin from '@/routes/admin';
const { can, hasRole } = useCan();
@ -184,8 +184,6 @@ const revenueChartConfig = {
},
} satisfies ChartConfig;
const activeRevenueChart = ref<'total' | 'net' | 'deduction'>('total');
const revenueTotals = computed(() => ({
total: props.revenueSummary.total_revenue,
net: props.revenueSummary.total_revenue - props.revenueSummary.total_deduction,
@ -214,8 +212,6 @@ const expenseChartConfig = {
},
} satisfies ChartConfig;
const activeExpenseChart = ref<'total' | 'purchase' | 'expense' | 'advance'>('total');
const expenseTotals = computed(() => ({
total: props.expenseSummary.total,
purchase: props.expenseSummary.purchase_total,
@ -233,6 +229,40 @@ const visibleExpenseCharts = computed(() => {
return charts;
});
const expenseYAccessors = computed(() => {
if (hasRole('admin-toko')) {
return [
(d: MonthlyExpenseData) => d.total,
(d: MonthlyExpenseData) => d.expense,
(d: MonthlyExpenseData) => d.advance,
];
}
return [
(d: MonthlyExpenseData) => d.total,
(d: MonthlyExpenseData) => d.purchase,
(d: MonthlyExpenseData) => d.expense,
(d: MonthlyExpenseData) => d.advance,
];
});
const expenseColors = computed(() => {
if (hasRole('admin-toko')) {
return [
expenseChartConfig.total.color,
expenseChartConfig.expense.color,
expenseChartConfig.advance.color,
];
}
return [
expenseChartConfig.total.color,
expenseChartConfig.purchase.color,
expenseChartConfig.expense.color,
expenseChartConfig.advance.color,
];
});
// Busy Hours Chart
type BusyHourData = { hour: string; orders: number };
@ -283,26 +313,40 @@ const barSelector = GroupedBar.selectors.bar;
function revenueTooltip(d: any) {
const item = d as MonthlyRevenueData;
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2 mt-0!">
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[activeRevenueChart.value].color}"></span>
<span class="text-muted-foreground">${revenueChartConfig[activeRevenueChart.value].label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item[activeRevenueChart.value])}</span>
<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>
<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>
<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.2">
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: ${expenseChartConfig[activeExpenseChart.value].color}"></span>
<span class="text-muted-foreground">${expenseChartConfig[activeExpenseChart.value].label}</span>
<span class="ml-auto font-medium tabular-nums text-foreground">Rp${formatRupiah(item[activeExpenseChart.value])}</span>
</p>
${rows}
</div>`;
}
@ -537,36 +581,48 @@ watch([startDate, endDate], () => {
<CardTitle>Pendapatan</CardTitle>
</div>
<div class="flex">
<button v-for="chart in ['total', 'net', 'deduction'] as const" :key="chart"
:data-active="activeRevenueChart === chart"
class="data-[active=true]:bg-muted/50 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"
@click="activeRevenueChart = chart">
<div v-for="chart in ['total', 'net', 'deduction'] as const" :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-muted-foreground text-xs">
{{ revenueChartConfig[chart].label }}
</span>
<span class="text-sm">
Rp{{ formatRupiah(revenueTotals[chart]) }}
</span>
</button>
</div>
</div>
</CardHeader>
<CardContent class="px-2 sm:p-6">
<ChartContainer v-if="monthlyRevenue.length > 0" :config="revenueChartConfig"
class="aspect-auto h-[300px] w-full">
<VisXYContainer :data="monthlyRevenue" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: MonthlyRevenueData, i: number) => i"
:y="(d: MonthlyRevenueData) => d[activeRevenueChart]"
:color="revenueChartConfig[activeRevenueChart].color" :bar-padding="0.1"
: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' + formatRupiah(d)" />
<VisTooltip :triggers="{ [barSelector]: revenueTooltip }" class-name="custom-tooltip" />
</VisXYContainer>
</ChartContainer>
<div v-if="monthlyRevenue.length > 0">
<div class="mb-3 flex flex-wrap items-center justify-center gap-4">
<div v-for="chart in ['total', 'net', 'deduction'] as const" :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="[
(d: MonthlyRevenueData) => d.total,
(d: MonthlyRevenueData) => d.net,
(d: MonthlyRevenueData) => d.deduction,
]" :color="[
revenueChartConfig.total.color,
revenueChartConfig.net.color,
revenueChartConfig.deduction.color,
]" :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>
@ -580,36 +636,41 @@ watch([startDate, endDate], () => {
<CardTitle>Pengeluaran</CardTitle>
</div>
<div class="flex">
<button v-for="chart in visibleExpenseCharts" :key="chart"
:data-active="activeExpenseChart === chart"
class="data-[active=true]:bg-muted/50 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"
@click="activeExpenseChart = chart">
<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-muted-foreground text-xs">
{{ expenseChartConfig[chart].label }}
</span>
<span class="text-sm">
Rp{{ formatRupiah(expenseTotals[chart]) }}
</span>
</button>
</div>
</div>
</CardHeader>
<CardContent class="px-2 sm:p-6">
<ChartContainer v-if="monthlyExpense.length > 0" :config="expenseChartConfig"
class="aspect-auto h-[300px] w-full">
<VisXYContainer :data="monthlyExpense" :y-domain="[0, undefined]">
<VisGroupedBar :x="(_d: MonthlyExpenseData, i: number) => i"
:y="(d: MonthlyExpenseData) => d[activeExpenseChart]"
:color="expenseChartConfig[activeExpenseChart].color" :bar-padding="0.1"
: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' + formatRupiah(d)" />
<VisTooltip :triggers="{ [barSelector]: expenseTooltip }" class-name="custom-tooltip" />
</VisXYContainer>
</ChartContainer>
<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>
@ -622,7 +683,6 @@ watch([startDate, endDate], () => {
<div class="flex items-center justify-between">
<div>
<CardTitle>Jam Sibuk Toko</CardTitle>
<CardDescription>Distribusi pesanan berdasarkan jam dalam sehari</CardDescription>
</div>
<div class="text-right">
<p class="text-sm text-muted-foreground">Jam Tersibuk</p>
@ -714,7 +774,7 @@ watch([startDate, endDate], () => {
: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' + formatRupiah(d)" />
:tick-format="(d: number) => 'Rp' + formatRupiahShort(d)" />
<VisTooltip :triggers="{ [barSelector]: supplierTooltip }"
class-name="custom-tooltip" />
</VisXYContainer>
@ -742,7 +802,7 @@ watch([startDate, endDate], () => {
: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' + formatRupiah(d)" />
:tick-format="(d: number) => 'Rp' + formatRupiahShort(d)" />
<VisTooltip :triggers="{ [barSelector]: customerTooltip }"
class-name="custom-tooltip" />
</VisXYContainer>