Compare commits
No commits in common. "40273637296826244f36c74156199c00a76c9fa1" and "d316266da739dc55b5fc968cea928964a00437ea" have entirely different histories.
4027363729
...
d316266da7
@ -34,8 +34,6 @@ public function index(Request $request): Response
|
||||
'productStock' => $this->analysisService->getProductStock(),
|
||||
'revenueSummary' => $this->analysisService->getRevenueSummary($startDate, $endDate),
|
||||
'monthlyRevenue' => $this->analysisService->getMonthlyRevenue($startDate, $endDate),
|
||||
'monthlyRevenueByChannel' => $this->analysisService->getMonthlyRevenueByChannel($startDate, $endDate),
|
||||
'revenueByPaymentType' => $this->analysisService->getRevenueByPaymentType($startDate, $endDate),
|
||||
'expenseSummary' => $this->analysisService->getExpenseSummary($startDate, $endDate),
|
||||
'monthlyExpense' => $this->analysisService->getMonthlyExpense($startDate, $endDate),
|
||||
'busyHours' => $this->analysisService->getBusyHours($startDate, $endDate),
|
||||
|
||||
@ -30,17 +30,9 @@ public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
|
||||
$tableQuery['channel'] = $request->string('channel')->toString();
|
||||
$tableQuery['status'] = $request->string('status')->toString();
|
||||
$tableQuery['payment_type'] = $request->string('payment_type')->toString();
|
||||
|
||||
return Inertia::render('admin/manage/orders/Index', [
|
||||
'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()),
|
||||
'filters' => $this->dataTableFilters($tableQuery, [
|
||||
'channel' => $tableQuery['channel'],
|
||||
'status' => $tableQuery['status'],
|
||||
'payment_type' => $tableQuery['payment_type'],
|
||||
]),
|
||||
'filters' => $this->dataTableFilters($tableQuery),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -25,7 +25,6 @@
|
||||
'shipping_cost_formatted',
|
||||
'subtotal_formatted',
|
||||
'total_formatted',
|
||||
'total_quantity_formatted',
|
||||
])]
|
||||
class Purchase extends Model implements HasMedia
|
||||
{
|
||||
@ -79,28 +78,6 @@ public function totalFormatted(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
public function totalQuantityFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$unit = $item->unit_abbreviation ?? $item->rawMaterialPrice?->rawMaterial?->unit?->abbreviation() ?? '';
|
||||
$groups[$unit] = ($groups[$unit] ?? 0) + (float) $item->quantity;
|
||||
}
|
||||
|
||||
return collect($groups)
|
||||
->filter(fn (float $total) => $total > 0)
|
||||
->map(function (float $total, string $unit) {
|
||||
$formatted = rtrim(rtrim(number_format($total, 2, ',', '.'), '0'), ',');
|
||||
return "{$formatted} {$unit}";
|
||||
})
|
||||
->join(', ');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Other Methods
|
||||
public static function mediaModuleName(): string
|
||||
{
|
||||
|
||||
@ -84,10 +84,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
||||
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
|
||||
});
|
||||
});
|
||||
})
|
||||
->when(($tableQuery['channel'] ?? '') !== '', fn (Builder $query) => $query->where('channel', $tableQuery['channel']))
|
||||
->when(($tableQuery['status'] ?? '') !== '', fn (Builder $query) => $query->where('status', $tableQuery['status']))
|
||||
->when(($tableQuery['payment_type'] ?? '') !== '', fn (Builder $query) => $query->where('payment_type', $tableQuery['payment_type']));
|
||||
});
|
||||
|
||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||
|
||||
|
||||
@ -3,9 +3,7 @@
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\ProductStatus;
|
||||
@ -400,94 +398,6 @@ public function getMonthlyRevenue(?Carbon $startDate = null, ?Carbon $endDate =
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getMonthlyRevenueByChannel(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = auth()->user();
|
||||
$isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false;
|
||||
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
|
||||
|
||||
$query = Order::query()->completed()
|
||||
->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
|
||||
->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id));
|
||||
|
||||
if ($startDate && $endDate) {
|
||||
$query->whereBetween('orders.created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
$monthlyChannelData = $query
|
||||
->selectRaw("
|
||||
DATE_FORMAT(orders.created_at, '%Y-%m') as month_key,
|
||||
channel,
|
||||
SUM(total_amount) as total_revenue
|
||||
")
|
||||
->groupBy('month_key', 'channel')
|
||||
->orderBy('month_key')
|
||||
->get()
|
||||
->groupBy('month_key');
|
||||
|
||||
if ($monthlyChannelData->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$start = $startDate ?? Carbon::parse($monthlyChannelData->keys()->first().'-01');
|
||||
$end = $endDate ?? Carbon::parse($monthlyChannelData->keys()->last().'-01')->endOfMonth();
|
||||
|
||||
$result = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
while ($current->lte($end)) {
|
||||
$key = $current->format('Y-m');
|
||||
$monthLabel = $current->locale('id')->translatedFormat('M Y');
|
||||
|
||||
$channels = $monthlyChannelData->get($key, collect());
|
||||
$channelMap = $channels->keyBy(fn ($item) => $item->channel instanceof OrderChannel
|
||||
? $item->channel->value
|
||||
: $item->channel);
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthLabel,
|
||||
'store' => (int) ($channelMap->get('store')?->total_revenue ?? 0),
|
||||
'shopee' => (int) ($channelMap->get('shopee')?->total_revenue ?? 0),
|
||||
'tiktok' => (int) ($channelMap->get('tiktok')?->total_revenue ?? 0),
|
||||
];
|
||||
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getRevenueByPaymentType(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = auth()->user();
|
||||
$isSuper = $user?->hasAnyRole(['developer', 'owner', 'direktur']) ?? false;
|
||||
$isMarketing = $user?->hasAnyRole(['marketing-offline', 'marketing-online']) ?? false;
|
||||
|
||||
$query = Order::query()->completed()
|
||||
->when(! $isSuper && $isMarketing, fn ($q) => $q->where('marketing_id', $user->id))
|
||||
->when(! $isSuper && ! $isMarketing, fn ($q) => $q->where('created_by_id', $user->id));
|
||||
|
||||
if ($startDate && $endDate) {
|
||||
$query->whereBetween('orders.created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
return $query
|
||||
->selectRaw('payment_type, SUM(total_amount) as total')
|
||||
->groupBy('payment_type')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'payment_type' => $item->payment_type instanceof PaymentType
|
||||
? $item->payment_type->value
|
||||
: $item->payment_type,
|
||||
'label' => $item->payment_type instanceof PaymentType
|
||||
? $item->payment_type->label()
|
||||
: PaymentType::from($item->payment_type)->label(),
|
||||
'total' => (int) $item->total,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getExpenseSummary(?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
/** @var User|null $user */
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export const OrderChannel = {
|
||||
STORE: 'store',
|
||||
TIKTOK: 'tiktok',
|
||||
SHOPEE: 'shopee',
|
||||
} as const;
|
||||
|
||||
@ -7,12 +7,10 @@ import {
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
} from '@lucide/vue';
|
||||
import { Donut, GroupedBar } from '@unovis/ts';
|
||||
import { GroupedBar } from '@unovis/ts';
|
||||
import {
|
||||
VisAxis,
|
||||
VisDonut,
|
||||
VisGroupedBar,
|
||||
VisSingleContainer,
|
||||
VisTooltip,
|
||||
VisXYContainer,
|
||||
} from '@unovis/vue';
|
||||
@ -104,17 +102,6 @@ const props = defineProps<{
|
||||
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;
|
||||
@ -245,7 +232,6 @@ const revenueChartConfig = {
|
||||
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:
|
||||
@ -281,106 +267,6 @@ 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;
|
||||
@ -484,24 +370,15 @@ const peakHour = computed(() => {
|
||||
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
||||
|
||||
onMounted(() => {
|
||||
const onResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
if (windowWidth.value < 640) return all.filter((_, i) => i % 4 === 0);
|
||||
if (windowWidth.value < 1024) return all.filter((_, i) => i % 2 === 0);
|
||||
return all;
|
||||
});
|
||||
|
||||
@ -510,15 +387,14 @@ const sectionOrder = computed(() => {
|
||||
return {
|
||||
statCards: 1,
|
||||
revenue: 5,
|
||||
revenueByChannel: 6,
|
||||
expense: 7,
|
||||
profitGross: 8,
|
||||
totalOrder: 9,
|
||||
marketingSales: 10,
|
||||
topSuppliers: 11,
|
||||
topProducts: 12,
|
||||
topCustomers: 13,
|
||||
busyHours: 14,
|
||||
expense: 6,
|
||||
profitGross: 7,
|
||||
totalOrder: 8,
|
||||
marketingSales: 9,
|
||||
topSuppliers: 10,
|
||||
topProducts: 11,
|
||||
topCustomers: 12,
|
||||
busyHours: 13,
|
||||
};
|
||||
}
|
||||
|
||||
@ -526,14 +402,13 @@ const sectionOrder = computed(() => {
|
||||
return {
|
||||
statCards: 1,
|
||||
revenue: 4,
|
||||
revenueByChannel: 5,
|
||||
expense: 6,
|
||||
profitGross: 7,
|
||||
totalOrder: 8,
|
||||
marketingSales: 9,
|
||||
topProducts: 10,
|
||||
topCustomers: 11,
|
||||
busyHours: 12,
|
||||
expense: 5,
|
||||
profitGross: 6,
|
||||
totalOrder: 7,
|
||||
marketingSales: 8,
|
||||
topProducts: 9,
|
||||
topCustomers: 10,
|
||||
busyHours: 11,
|
||||
};
|
||||
}
|
||||
|
||||
@ -541,10 +416,9 @@ const sectionOrder = computed(() => {
|
||||
return {
|
||||
statCards: 1,
|
||||
revenue: 2,
|
||||
revenueByChannel: 3,
|
||||
totalOrder: 4,
|
||||
topProducts: 5,
|
||||
topCustomers: 6,
|
||||
totalOrder: 3,
|
||||
topProducts: 4,
|
||||
topCustomers: 5,
|
||||
};
|
||||
}
|
||||
|
||||
@ -818,52 +692,55 @@ watch([startDate, endDate], () => {
|
||||
<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" />
|
||||
{
|
||||
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',
|
||||
},
|
||||
]" />
|
||||
" :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="(
|
||||
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="[
|
||||
).toLocaleString('id-ID')"
|
||||
:sub-label="'Rp' + formatRupiah(productStock.total_value)"
|
||||
:items="[
|
||||
{
|
||||
label: 'Stok Bagus',
|
||||
value: productStock.total_stock.toLocaleString(
|
||||
@ -913,21 +790,20 @@ watch([startDate, endDate], () => {
|
||||
}"></span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
revenueChartConfig[chart].label
|
||||
}}</span>
|
||||
}}</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" />
|
||||
" :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 ?? ''
|
||||
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)
|
||||
'Rp' + formatRupiahShort(d)
|
||||
" />
|
||||
<VisTooltip :triggers="{
|
||||
[barSelector]: revenueTooltip,
|
||||
@ -941,85 +817,6 @@ watch([startDate, endDate], () => {
|
||||
</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">
|
||||
@ -1048,7 +845,7 @@ watch([startDate, endDate], () => {
|
||||
}"></span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
expenseChartConfig[chart].label
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChartContainer :config="expenseChartConfig" class="aspect-auto h-[300px] w-full">
|
||||
@ -1058,11 +855,11 @@ watch([startDate, endDate], () => {
|
||||
: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 ?? ''
|
||||
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)
|
||||
'Rp' + formatRupiahShort(d)
|
||||
" />
|
||||
<VisTooltip :triggers="{
|
||||
[barSelector]: expenseTooltip,
|
||||
@ -1081,49 +878,49 @@ watch([startDate, endDate], () => {
|
||||
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="[
|
||||
" :items="[
|
||||
{
|
||||
label: 'Pendapatan',
|
||||
value:
|
||||
'Rp' +
|
||||
formatRupiah(revenueSummary.total_revenue),
|
||||
},
|
||||
{
|
||||
label: 'HPP',
|
||||
value: 'Rp' + formatRupiah(profitMetrics.hpp),
|
||||
},
|
||||
...(!hasRole('cashier') ? [
|
||||
{
|
||||
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 +
|
||||
'%)',
|
||||
},
|
||||
] : []),
|
||||
]" :style="{ order: sectionOrder.profitGross ?? 99 }" />
|
||||
] : []),
|
||||
]" :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 }" />
|
||||
{
|
||||
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 }">
|
||||
@ -1215,15 +1012,16 @@ watch([startDate, endDate], () => {
|
||||
<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" />
|
||||
<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 ?? ''
|
||||
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)
|
||||
'Rp' + formatRupiahShort(d)
|
||||
" />
|
||||
<VisTooltip :triggers="{
|
||||
[barSelector]: supplierTooltip,
|
||||
@ -1250,9 +1048,9 @@ watch([startDate, endDate], () => {
|
||||
: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 ?? ''
|
||||
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,
|
||||
@ -1275,15 +1073,16 @@ watch([startDate, endDate], () => {
|
||||
<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" />
|
||||
<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 ?? ''
|
||||
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)
|
||||
'Rp' + formatRupiahShort(d)
|
||||
" />
|
||||
<VisTooltip :triggers="{
|
||||
[barSelector]: customerTooltip,
|
||||
|
||||
@ -14,13 +14,9 @@ import {
|
||||
getPaperSizeLabel,
|
||||
useThermalPrinter,
|
||||
} from '@/composables/useThermalPrinter';
|
||||
import { OrderChannel } from '@/constants/order-channel';
|
||||
import { OrderPaymentType } from '@/constants/order-payment-type';
|
||||
import { OrderStatus } from '@/constants/order-status';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { PaperSize } from '@/lib/thermal-printer/types';
|
||||
import { index, create } from '@/routes/admin/manage/orders';
|
||||
import type { DataTableFilterDef } from '@/types/data-table';
|
||||
import type { PaginatedOrders } from '@/types/order';
|
||||
import OrderGroupedTable from './table/OrderGroupedTable.vue';
|
||||
|
||||
@ -30,9 +26,6 @@ const props = defineProps<{
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
channel?: string;
|
||||
status?: string;
|
||||
payment_type?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
@ -41,55 +34,13 @@ const page = usePage();
|
||||
const { isConnected, printOrderReceipt, tryReconnect } = useThermalPrinter();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
|
||||
const { query, setSearch, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
const { setSearch, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: index.url(),
|
||||
initial: { ...props.filters },
|
||||
filterKeys: ['channel', 'status', 'payment_type'],
|
||||
});
|
||||
|
||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||
|
||||
const filterDefs = computed<DataTableFilterDef[]>(() => [
|
||||
{
|
||||
key: 'channel',
|
||||
label: 'Channel',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: OrderChannel.STORE, label: 'Toko' },
|
||||
{ value: OrderChannel.SHOPEE, label: 'Shopee' },
|
||||
{ value: OrderChannel.TIKTOK, label: 'TikTok' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: OrderStatus.PENDING, label: 'Menunggu' },
|
||||
{ value: OrderStatus.PROCESSING, label: 'Diproses' },
|
||||
{ value: OrderStatus.COMPLETED, label: 'Selesai' },
|
||||
{ value: OrderStatus.CANCELLED, label: 'Dibatalkan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'payment_type',
|
||||
label: 'Tipe Pembayaran',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: OrderPaymentType.CASH, label: 'Cash' },
|
||||
{ value: OrderPaymentType.TRANSFER, label: 'Transfer' },
|
||||
{ value: OrderPaymentType.QRIS, label: 'QRIS' },
|
||||
{ value: OrderPaymentType.MARKETPLACE, label: 'Marketplace' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const filterValues = computed(() => ({
|
||||
channel: query.value.channel ?? '',
|
||||
status: query.value.status ?? '',
|
||||
payment_type: query.value.payment_type ?? '',
|
||||
}));
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
currentPage: props.orders.current_page,
|
||||
perPage: props.orders.per_page,
|
||||
@ -187,9 +138,6 @@ onMounted(async () => {
|
||||
:first-item="firstItem"
|
||||
:pagination="tablePagination"
|
||||
:pagination-links="orders.links"
|
||||
:filter-defs="filterDefs"
|
||||
:filter-values="filterValues"
|
||||
@filter-change="setFilter"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -21,7 +21,6 @@ import { usePaginationSummary } from '@/composables/usePaginationSummary';
|
||||
import { orderStatusBadgeVariant } from '@/constants/order-status';
|
||||
import { groupedTableRowNumber } from '@/lib/grouped-table';
|
||||
import type {
|
||||
DataTableFilterDef,
|
||||
DataTablePagination,
|
||||
DataTablePaginationLink,
|
||||
} from '@/types/data-table';
|
||||
@ -33,14 +32,11 @@ const props = defineProps<{
|
||||
firstItem?: number;
|
||||
pagination?: DataTablePagination;
|
||||
paginationLinks?: DataTablePaginationLink[];
|
||||
filterDefs?: DataTableFilterDef[];
|
||||
filterValues?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'filter-change': [key: string, value: string];
|
||||
'filters-reset': [];
|
||||
}>();
|
||||
|
||||
@ -54,8 +50,7 @@ function rowNumber(index: number): number {
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
|
||||
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
||||
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
|
||||
|
||||
<div v-if="orders.length" class="space-y-4">
|
||||
<div v-for="(order, index) in orders" :key="order.id" class="overflow-hidden rounded-md border">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Check, Package, Plus, Search, ShoppingCart } from '@lucide/vue';
|
||||
import { Check, Plus, Search, ShoppingCart } from '@lucide/vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@ -816,15 +816,6 @@ function submit() {
|
||||
: 'cursor-pointer hover:bg-accent'"
|
||||
@click="!isInCart(result.priceId) && selectExistingMaterial(result)"
|
||||
>
|
||||
<div class="size-10 shrink-0 overflow-hidden rounded border bg-muted/30">
|
||||
<img v-if="result.images.length > 0"
|
||||
:src="result.images[0].thumb_url"
|
||||
:alt="result.variant"
|
||||
class="size-full object-cover" />
|
||||
<div v-else class="flex size-full items-center justify-center text-muted-foreground">
|
||||
<Package class="size-4 opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">{{ selectedMaterialId ? '' : result.name + ' → ' }}{{ result.variant }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
|
||||
@ -145,8 +145,6 @@ function openVerificationDetail(requestId: number | undefined) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span v-if="purchase.total_quantity_formatted">Total Jumlah
|
||||
<strong class="text-primary">{{ purchase.total_quantity_formatted }}</strong></span>
|
||||
<span>Subtotal <strong class="text-primary">{{ purchase.subtotal_formatted
|
||||
}}</strong></span>
|
||||
<span>Diskon <strong class="text-primary">{{ purchase.discount_formatted
|
||||
|
||||
@ -41,7 +41,6 @@ export type PurchaseListItem = {
|
||||
shipping_cost: number;
|
||||
shipping_cost_formatted: string;
|
||||
total_formatted: string;
|
||||
total_quantity_formatted?: string;
|
||||
notes: string | null;
|
||||
created_at_formatted: string;
|
||||
created_by_name?: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user