feat: enhance analysis page with additional revenue metrics and improve data handling

This commit is contained in:
Yoga Pangestu 2026-08-08 13:40:18 +07:00
parent f7c13826eb
commit 6627422655
3 changed files with 69 additions and 59 deletions

View File

@ -70,7 +70,7 @@ protected function formattedRemainingAmount(): Attribute
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
get: fn () => $this->status?->label(),
);
}

View File

@ -3,6 +3,8 @@
namespace App\Services;
use App\Enums\OrderStatus;
use App\Enums\PriceType;
use App\Enums\RawMaterialUnit;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
@ -34,7 +36,7 @@ public function getAttendanceStats(?string $startDate, ?string $endDate): array
$current->addDay();
}
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$totalEmployees = Employee::whereHas('user', fn($q) => $q->where('is_active', true))->count();
$present = Attendance::whereBetween('attendance_date', [$start, $end])->count();
@ -128,24 +130,24 @@ public function getCashOverview(): array
return [
'total_balance' => $cashAccount->balance,
'total_transactions' => (clone $transactions)->count(),
'total_deposit' => (clone $transactions)->where('type', 'deposit')->sum('amount'),
'total_withdrawal' => (clone $transactions)->where('type', 'withdrawal')->sum('amount'),
'total_deposit' => (int) (clone $transactions)->where('type', 'deposit')->sum('amount'),
'total_withdrawal' => (int) (clone $transactions)->where('type', 'withdrawal')->sum('amount'),
];
}
public function getRawMaterialStock(): array
{
$prices = RawMaterialPrice::select('stock', 'price')
$prices = RawMaterialPrice::select('stock', 'price', 'raw_material_id')
->with('rawMaterial:id,unit')
->get();
$totalStock = $prices->sum('stock');
$totalValue = $prices->sum(fn ($p) => $p->stock * $p->price);
$totalValue = $prices->sum(fn($p) => $p->stock * $p->price);
$byUnit = [
'yard' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'yard')->sum('stock'),
'meter' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'meter')->sum('stock'),
'kilogram' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === 'kg')->sum('stock'),
'yard' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'),
'meter' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'),
'kilogram' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'),
];
return [
@ -157,8 +159,8 @@ public function getRawMaterialStock(): array
public function getProductStock(): array
{
$variants = ProductVariant::select('stock', 'reject_stock', 'retail_stock')
->with('product:id,name')
$variants = ProductVariant::select('id', 'product_id', 'stock', 'reject_stock', 'retail_stock')
->with(['product:id,name', 'productPrices' => fn ($q) => $q->where('type', PriceType::CAPITAL)])
->get();
$totalStock = $variants->sum('stock');
@ -166,9 +168,9 @@ public function getProductStock(): array
$totalRetail = $variants->sum('retail_stock');
$totalValue = $variants->sum(function ($v) {
$retailPrice = $v->productPrices()->where('type', 'retail')->first()?->price ?? 0;
$capitalPrice = $v->productPrices->first()?->price ?? 0;
return $v->stock * $retailPrice;
return $v->stock * $capitalPrice;
});
return [
@ -191,15 +193,19 @@ public function getRevenueSummary(?string $startDate, ?string $endDate): array
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) - COALESCE(SUM(total_amount), 0) as total_marketplace_fees')
->selectRaw('COALESCE(SUM(discount), 0) as total_deduction')
->selectRaw('COALESCE(SUM(total_amount) - SUM(discount), 0) as net')
->selectRaw("COALESCE(SUM(CASE WHEN price_type != 'retail' THEN total_amount ELSE 0 END), 0) as net_warehouse")
->selectRaw("COALESCE(SUM(CASE WHEN price_type = 'retail' THEN total_amount ELSE 0 END), 0) as net_retail")
->first();
return [
'total_revenue' => (int) $stats->total_revenue,
'total_discount' => (int) $stats->total_discount,
'total_marketplace_fees' => (int) $stats->total_marketplace_fees,
'total_deduction' => (int) $stats->total_deduction,
'net' => (int) $stats->net,
'net_warehouse' => (int) $stats->net_warehouse,
'net_retail' => (int) $stats->net_retail,
'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
];
@ -214,14 +220,15 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->selectRaw('COALESCE(SUM(total_amount) - SUM(discount), 0) as net')
->selectRaw('0 as net_warehouse')
->selectRaw('0 as net_retail')
->selectRaw("COALESCE(SUM(CASE WHEN price_type != 'retail' THEN total_amount ELSE 0 END), 0) as net_warehouse")
->selectRaw("COALESCE(SUM(CASE WHEN price_type = 'retail' THEN total_amount ELSE 0 END), 0) as net_retail")
->selectRaw('COALESCE(SUM(discount), 0) as deduction')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get();
->get()
->map(fn($item) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction']));
return $monthly->toArray();
return $monthly->values()->toArray();
}
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
@ -236,9 +243,10 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate)
->selectRaw("SUM(CASE WHEN channel = 'tiktok' THEN total_amount ELSE 0 END) as tiktok")
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get();
->get()
->map(fn($item) => $item->only(['month', 'store', 'shopee', 'tiktok']));
return $monthly->toArray();
return $monthly->values()->toArray();
}
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
@ -251,7 +259,7 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate): a
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn ($item) => [
->map(fn($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,
@ -288,7 +296,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate);
$expenseByMonth = (clone $expenseMonthly)
$expenseByMonth = (clone $expenseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
@ -298,7 +306,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
$purchaseMonthly = Purchase::query();
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate);
$purchaseByMonth = (clone $purchaseMonthly)
$purchaseByMonth = (clone $purchaseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total), 0) as purchase')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
@ -308,30 +316,30 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceMonthly, $startDate, $endDate);
$advanceByMonth = (clone $advanceMonthly)
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = collect();
$allMonths = [];
foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) {
foreach ($data as $month => $row) {
if (! $allMonths->has($month)) {
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = ['month' => $month, 'total' => 0, 'purchase' => 0, 'expense' => 0, 'advance' => 0];
}
}
}
foreach ($allMonths as $month => &$row) {
$row['purchase'] = (int) ($purchaseByMonth[$month]['purchase'] ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]['expense'] ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]['advance'] ?? 0);
$row['purchase'] = (int) ($purchaseByMonth[$month]->purchase ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0);
$row['total'] = $row['purchase'] + $row['expense'] + $row['advance'];
}
return array_values($allMonths->toArray());
return array_values($allMonths);
}
public function getBusyHours(?string $startDate, ?string $endDate): array
@ -393,7 +401,7 @@ public function getTopSuppliers(?string $startDate, ?string $endDate): array
$query = Purchase::query();
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
return (clone $query)->toBase()
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
->select('suppliers.name')
->selectRaw('COALESCE(SUM(purchases.total), 0) as total_amount')
@ -410,7 +418,7 @@ public function getTopCustomers(?string $startDate, ?string $endDate): array
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
return (clone $query)->toBase()
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_amount')
@ -427,7 +435,7 @@ public function getTopProducts(?string $startDate, ?string $endDate): array
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
return (clone $query)->toBase()
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
@ -447,7 +455,7 @@ public function getMarketingSales(?string $startDate, ?string $endDate): array
->whereNotNull('orders.marketing_id');
$this->applyDateFilter($query, $startDate, $endDate);
return (clone $query)
return (clone $query)->toBase()
->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
->leftJoin('order_items', 'orders.id', '=', 'order_items.order_id')
@ -464,13 +472,13 @@ public function getMarketingSales(?string $startDate, ?string $endDate): array
->toArray();
}
private function applyDateFilter($query, ?string $startDate, ?string $endDate): void
private function applyDateFilter($query, ?string $startDate, ?string $endDate, string $dateColumn = 'created_at'): void
{
if ($startDate) {
$query->whereDate('created_at', '>=', $startDate);
$query->whereDate($dateColumn, '>=', $startDate);
}
if ($endDate) {
$query->whereDate('created_at', '<=', $endDate);
$query->whereDate($dateColumn, '<=', $endDate);
}
}
}

View File

@ -1,3 +1,17 @@
import { StatCard } from '@/components/card/stat-card';
import { DatePicker } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
import { Head, router } from '@inertiajs/react';
import {
Banknote,
@ -19,20 +33,6 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { StatCard } from '@/components/card/stat-card';
import { DatePicker } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { formatRupiah, formatRupiahShort } from '@/lib/rupiah';
type Filters = {
start_date?: string;
@ -83,8 +83,10 @@ type AnalysisProps = {
revenueSummary: {
total_revenue: number;
total_discount: number;
total_marketplace_fees: number;
total_deduction: number;
net: number;
net_warehouse: number;
net_retail: number;
total_orders: number;
avg_order: number;
};
@ -264,7 +266,6 @@ export default function Analysis({
marketingSales,
}: AnalysisProps) {
const { can, hasAnyRole, hasRole } = useCan();
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
const [endDate, setEndDate] = useState(initialFilters.end_date ?? '');
const [selectedPreset, setSelectedPreset] = useState('');
@ -497,18 +498,18 @@ export default function Analysis({
<div className="flex flex-col sm:flex-row">
{Object.entries(REVENUE_COLORS).filter(([key]) => {
if (hasAnyRole(['owner', 'developer'])) {
return true;
}
return true;
}
if (hasRole('cashier')) {
return key === 'total' || key === 'deduction';
}
return key === 'total' || key === 'deduction';
}
return key === 'total' || key === 'net' || key === 'deduction';
}).map(([key]) => (
<div key={key} className="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 className="text-xs text-muted-foreground">{key === 'net_warehouse' ? 'Total Gudang' : key === 'net_retail' ? 'Total Ecer' : key === 'total' ? 'Total' : key === 'net' ? 'Bersih' : 'Potongan'}</span>
<span className="text-sm">Rp{formatRupiah(key === 'total' ? revenueSummary.total_revenue : key === 'deduction' ? revenueSummary.total_deduction : 0)}</span>
<span className="text-sm">Rp{formatRupiah(key === 'total' ? revenueSummary.total_revenue : key === 'net' ? revenueSummary.net : key === 'net_warehouse' ? revenueSummary.net_warehouse : key === 'net_retail' ? revenueSummary.net_retail : revenueSummary.total_deduction)}</span>
</div>
))}
</div>
@ -641,6 +642,7 @@ return key === 'total' || key === 'deduction';
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `Rp${formatRupiahShort(v)}`} />
<Tooltip content={<BarTooltip />} />
<Bar dataKey="total" fill={EXPENSE_COLORS.total} radius={[4, 4, 0, 0]} name="Total" />
<Bar dataKey="purchase" fill={EXPENSE_COLORS.purchase} radius={[4, 4, 0, 0]} name="Belanja" />
<Bar dataKey="expense" fill={EXPENSE_COLORS.expense} radius={[4, 4, 0, 0]} name="Pengeluaran" />
<Bar dataKey="advance" fill={EXPENSE_COLORS.advance} radius={[4, 4, 0, 0]} name="Kasbon" />
</BarChart>