feat: enhance permissions and layout in Analysis component for better role-based display
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-20 22:35:10 +07:00
parent b7f8bf7e0f
commit 1e8fd5b595
2 changed files with 227 additions and 183 deletions

View File

@ -132,7 +132,8 @@ public function permissions(): array
Permission::ANALYSIS_EXPENSE,
Permission::ANALYSIS_BUSY_HOURS,
Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_TOP_SUPPLIERS,
Permission::ANALYSIS_PROFIT_HPP,
Permission::ANALYSIS_PROFIT_GROSS,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES,
@ -332,6 +333,7 @@ public function permissions(): array
Permission::ANALYSIS_REVENUE,
Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,

View File

@ -382,6 +382,49 @@ const busyHourTickValues = computed(() => {
return all;
});
const sectionOrder = computed(() => {
if (hasAnyRole(['owner', 'developer'])) {
return {
statCards: 1,
revenue: 5,
expense: 6,
profitGross: 7,
totalOrder: 8,
marketingSales: 9,
topSuppliers: 10,
topProducts: 11,
topCustomers: 12,
busyHours: 13,
};
}
if (hasAnyRole(['admin_toko', 'direktur'])) {
return {
statCards: 1,
revenue: 4,
expense: 5,
profitGross: 6,
totalOrder: 7,
marketingSales: 8,
topProducts: 9,
topCustomers: 10,
busyHours: 11,
};
}
if (hasRole('marketing')) {
return {
statCards: 1,
revenue: 2,
totalOrder: 3,
topProducts: 4,
topCustomers: 5,
};
}
return {};
});
// Top 5 Chart configs
type SupplierData = { name: string; amount: number };
type CustomerData = { name: string; amount: number };
@ -609,8 +652,8 @@ watch([startDate, endDate], () => {
</div>
</div>
<!-- Kehadiran & Kas Toko -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- 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="[
@ -720,7 +763,7 @@ watch([startDate, endDate], () => {
</div>
<!-- Revenue Chart -->
<Card v-if="can('analysis.revenue')" class="py-4 sm:py-0">
<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>
@ -775,7 +818,7 @@ watch([startDate, endDate], () => {
</Card>
<!-- Expense Chart -->
<Card v-if="can('analysis.expense')" class="py-4 sm:py-0">
<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>
@ -830,191 +873,57 @@ watch([startDate, endDate], () => {
</CardContent>
</Card>
<!-- Busy Hours Chart -->
<Card v-if="can('analysis.busy_hours')">
<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>
<!-- Profit Metrics Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<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="[
<!-- 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: 'Produk Terjual',
value: profitMetrics.total_products_sold.toLocaleString(
'id-ID',
),
},
{
label: 'Item/Transaksi',
value: profitMetrics.items_per_transaction,
},
{
label: 'AOV',
value: 'Rp' + formatRupiah(profitMetrics.aov),
},
]" />
<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',
label: 'Laba Bersih',
value:
'Rp' +
formatRupiah(revenueSummary.total_revenue),
formatRupiah(profitMetrics.net_profit) +
' (' +
profitMetrics.profit_margin +
'%)',
},
{
label: 'HPP',
value: 'Rp' + formatRupiah(profitMetrics.hpp),
},
...(!hasRole('cashier') ? [
{
label: 'Laba Bersih',
value:
'Rp' +
formatRupiah(profitMetrics.net_profit) +
' (' +
profitMetrics.profit_margin +
'%)',
},
] : []),
]" />
</div>
] : []),
]" :style="{ order: sectionOrder.profitGross ?? 99 }" />
<!-- Top 5 Charts -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-if="can('analysis.top_suppliers')">
<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>
<Card v-if="can('analysis.top_customers')">
<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>
<Card v-if="can('analysis.top_products')">
<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>
</div>
<!-- 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')">
<Card v-if="can('analysis.marketing_sales')" :style="{ order: sectionOrder.marketingSales ?? 99 }">
<CardHeader>
<CardTitle>Penjualan Marketing</CardTitle>
<CardDescription>Rekap penjualan per marketing</CardDescription>
@ -1092,6 +1001,139 @@ watch([startDate, endDate], () => {
</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>