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_EXPENSE,
Permission::ANALYSIS_BUSY_HOURS, Permission::ANALYSIS_BUSY_HOURS,
Permission::ANALYSIS_PROFIT_ORDERS, Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_TOP_SUPPLIERS, Permission::ANALYSIS_PROFIT_HPP,
Permission::ANALYSIS_PROFIT_GROSS,
Permission::ANALYSIS_TOP_CUSTOMERS, Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ANALYSIS_TOP_PRODUCTS, Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES, Permission::ANALYSIS_MARKETING_SALES,
@ -332,6 +333,7 @@ public function permissions(): array
Permission::ANALYSIS_REVENUE, Permission::ANALYSIS_REVENUE,
Permission::ANALYSIS_PROFIT_ORDERS, Permission::ANALYSIS_PROFIT_ORDERS,
Permission::ANALYSIS_TOP_PRODUCTS, Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_TOP_CUSTOMERS,
Permission::ATTENDANCES_VIEW, Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE, Permission::ATTENDANCES_CREATE,

View File

@ -382,6 +382,49 @@ const busyHourTickValues = computed(() => {
return all; 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 // Top 5 Chart configs
type SupplierData = { name: string; amount: number }; type SupplierData = { name: string; amount: number };
type CustomerData = { name: string; amount: number }; type CustomerData = { name: string; amount: number };
@ -609,8 +652,8 @@ watch([startDate, endDate], () => {
</div> </div>
</div> </div>
<!-- Kehadiran & Kas Toko --> <!-- Stat Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <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" <StatCard v-if="can('analysis.attendance') && isManager" title="Kehadiran" :icon="UserCheck"
main-label="Total Karyawan" :main-value="attendance.total_employees" main-label="Total Karyawan" :main-value="attendance.total_employees"
:sub-label="attendance.percentage + '% hadir'" :items="[ :sub-label="attendance.percentage + '% hadir'" :items="[
@ -720,7 +763,7 @@ watch([startDate, endDate], () => {
</div> </div>
<!-- Revenue Chart --> <!-- 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"> <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"> <div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Pendapatan</CardTitle> <CardTitle>Pendapatan</CardTitle>
@ -775,7 +818,7 @@ watch([startDate, endDate], () => {
</Card> </Card>
<!-- Expense Chart --> <!-- 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"> <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"> <div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Pengeluaran</CardTitle> <CardTitle>Pengeluaran</CardTitle>
@ -830,191 +873,57 @@ watch([startDate, endDate], () => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Busy Hours Chart --> <!-- Laba Kotor -->
<Card v-if="can('analysis.busy_hours')"> <StatCard v-if="
<CardHeader> can('analysis.profit_gross') ||
<div class="flex items-center justify-between"> can('analysis.profit_hpp')
<div> " title="Laba Kotor" :icon="TrendingUp" main-label="Laba Kotor" :main-value="'Rp' + formatRupiah(profitMetrics.gross_profit)
<CardTitle>Jam Sibuk Toko</CardTitle> " :items="[
</div> {
<div class="text-right"> label: 'Pendapatan',
<p class="text-sm text-muted-foreground"> value:
Jam Tersibuk 'Rp' +
</p> formatRupiah(revenueSummary.total_revenue),
<p class="text-2xl font-bold text-primary"> },
{{ peakHour.hour }} {
</p> label: 'HPP',
<p class="text-xs text-muted-foreground"> value: 'Rp' + formatRupiah(profitMetrics.hpp),
{{ peakHour.orders }} pesanan },
</p> ...(!hasRole('cashier') ? [
</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="[
{ {
label: 'Produk Terjual', label: 'Laba Bersih',
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',
value: value:
'Rp' + 'Rp' +
formatRupiah(revenueSummary.total_revenue), formatRupiah(profitMetrics.net_profit) +
' (' +
profitMetrics.profit_margin +
'%)',
}, },
{ ] : []),
label: 'HPP', ]" :style="{ order: sectionOrder.profitGross ?? 99 }" />
value: 'Rp' + formatRupiah(profitMetrics.hpp),
},
...(!hasRole('cashier') ? [
{
label: 'Laba Bersih',
value:
'Rp' +
formatRupiah(profitMetrics.net_profit) +
' (' +
profitMetrics.profit_margin +
'%)',
},
] : []),
]" />
</div>
<!-- Top 5 Charts --> <!-- Total Order -->
<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"
<Card v-if="can('analysis.top_suppliers')"> main-label="Pesanan Selesai" :main-value="profitMetrics.total_orders.toLocaleString('id-ID')
<CardHeader> " :items="[
<CardTitle class="text-base">Top 5 Supplier</CardTitle> {
<CardDescription>Berdasarkan total harga pembelian</CardDescription> label: 'Produk Terjual',
</CardHeader> value: profitMetrics.total_products_sold.toLocaleString(
<CardContent> 'id-ID',
<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" label: 'Item/Transaksi',
:y="(d: SupplierData) => d.amount" :color="supplierChartConfig.amount.color" value: profitMetrics.items_per_transaction,
: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) => label: 'Rata-rata/Transaksi',
supplierBarData[d]?.name ?? '' value: 'Rp' + formatRupiah(profitMetrics.aov),
" :tick-values="supplierBarData.map((_, i) => i) },
" /> ]" :style="{ order: sectionOrder.totalOrder ?? 99 }" />
<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>
<!-- Marketing Sales Table --> <!-- Marketing Sales Table -->
<Card v-if="can('analysis.marketing_sales')"> <Card v-if="can('analysis.marketing_sales')" :style="{ order: sectionOrder.marketingSales ?? 99 }">
<CardHeader> <CardHeader>
<CardTitle>Penjualan Marketing</CardTitle> <CardTitle>Penjualan Marketing</CardTitle>
<CardDescription>Rekap penjualan per marketing</CardDescription> <CardDescription>Rekap penjualan per marketing</CardDescription>
@ -1092,6 +1001,139 @@ watch([startDate, endDate], () => {
</div> </div>
</CardContent> </CardContent>
</Card> </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> </div>
</AdminLayout> </AdminLayout>
</template> </template>