feat: enhance revenue analysis by adding payroll and expense calculations

This commit is contained in:
Yoga Pangestu 2026-08-14 00:32:37 +07:00
parent 78c432eaa0
commit 0aae00a96b
5 changed files with 148 additions and 26 deletions

View File

@ -7,6 +7,7 @@
use App\Enums\OrderChannel; use App\Enums\OrderChannel;
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PayrollStatus;
use App\Enums\RawMaterialUnit; use App\Enums\RawMaterialUnit;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
@ -16,6 +17,7 @@
use App\Models\Expense; use App\Models\Expense;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Order; use App\Models\Order;
use App\Models\Payroll;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\PurchaseItem; use App\Models\PurchaseItem;
use App\Models\RestockItem; use App\Models\RestockItem;
@ -260,12 +262,28 @@ public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $u
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs') ->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->first(); ->first();
$payrollQuery = Payroll::where('status', PayrollStatus::PAID);
$this->applyDateFilter($payrollQuery, $startDate, $endDate, 'paid_at');
$payrollTotal = (int) $payrollQuery->sum('total_amount');
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$expenseTotal = (int) $expenseQuery->sum('amount');
$totalRevenue = (int) $stats->total_revenue;
$totalCogs = (int) $stats->total_cogs;
$gross = $totalRevenue - $totalCogs;
$net = $gross - $payrollTotal - $expenseTotal;
return [ return [
'total_revenue' => (int) $stats->total_revenue, 'total_revenue' => $totalRevenue,
'total_discount' => (int) $stats->total_discount, 'total_discount' => (int) $stats->total_discount,
'total_deduction' => (int) $stats->total_deduction, 'total_deduction' => (int) $stats->total_deduction,
'cogs' => (int) $stats->total_cogs, 'cogs' => $totalCogs,
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs, 'gross' => $gross,
'net' => $net,
'payroll_total' => $payrollTotal,
'expense_total' => $expenseTotal,
'total_orders' => (int) $stats->total_orders, 'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0, 'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
]; ];
@ -283,23 +301,68 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate, ?User $u
$monthly = (clone $query) $monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month") ->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total') ->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->selectRaw('COALESCE(SUM(total_amount) - SUM(cogs), 0) as net')
->selectRaw('COALESCE(SUM(discount), 0) as discount') ->selectRaw('COALESCE(SUM(discount), 0) as discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction') ->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as cogs') ->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')")) ->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')")) ->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get() ->get()
->map(fn ($item) => [ ->keyBy('month');
'month' => $item->month,
'total' => (int) $item->total,
'net' => (int) $item->net,
'discount' => (int) $item->discount,
'deduction' => (int) $item->deduction,
'cogs' => (int) $item->cogs,
]);
return $monthly->values()->toArray(); $payrollMonthly = Payroll::where('status', PayrollStatus::PAID);
$this->applyDateFilter($payrollMonthly, $startDate, $endDate, 'paid_at');
$payrollByMonth = (clone $payrollMonthly)->toBase()
->selectRaw("DATE_FORMAT(paid_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as payroll')
->groupBy(DB::raw("DATE_FORMAT(paid_at, '%Y-%m')"), DB::raw("DATE_FORMAT(paid_at, '%b %Y')"))
->get()
->keyBy('month');
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
$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')"))
->get()
->keyBy('month');
$allMonths = [];
foreach ([$monthly, $payrollByMonth, $expenseByMonth] as $data) {
foreach ($data as $month => $row) {
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = $month;
}
}
}
$result = [];
foreach ($allMonths as $month => $_) {
$total = (int) ($monthly[$month]->total ?? 0);
$discount = (int) ($monthly[$month]->discount ?? 0);
$deduction = (int) ($monthly[$month]->deduction ?? 0);
$cogs = (int) ($monthly[$month]->cogs ?? 0);
$payroll = (int) ($payrollByMonth[$month]->payroll ?? 0);
$expense = (int) ($expenseByMonth[$month]->expense ?? 0);
$gross = $total - $cogs;
$net = $gross - $payroll - $expense;
$result[] = [
'month' => $month,
'total' => $total,
'gross' => $gross,
'net' => $net,
'discount' => $discount,
'deduction' => $deduction,
'cogs' => $cogs,
'payroll' => $payroll,
'expense' => $expense,
];
}
return $result;
} }
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate, ?User $user = null): array public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate, ?User $user = null): array
@ -541,8 +604,16 @@ public function getProfitMetrics(?string $startDate, ?string $endDate, ?User $us
->join('order_items', 'orders.id', '=', 'order_items.order_id') ->join('order_items', 'orders.id', '=', 'order_items.order_id')
->sum('order_items.quantity'); ->sum('order_items.quantity');
$payrollQuery = Payroll::where('status', PayrollStatus::PAID);
$this->applyDateFilter($payrollQuery, $startDate, $endDate, 'paid_at');
$payrollTotal = (int) $payrollQuery->sum('total_amount');
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$expenseTotal = (int) $expenseQuery->sum('amount');
$grossProfit = $stats->total_revenue - $stats->hpp; $grossProfit = $stats->total_revenue - $stats->hpp;
$netProfit = $grossProfit; $netProfit = $grossProfit - $payrollTotal - $expenseTotal;
$profitMargin = $stats->total_revenue > 0 ? round(($netProfit / $stats->total_revenue) * 100, 1) : 0; $profitMargin = $stats->total_revenue > 0 ? round(($netProfit / $stats->total_revenue) * 100, 1) : 0;
$aov = $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0; $aov = $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0;
$itemsPerTransaction = $stats->total_orders > 0 ? round($totalProductsSold / $stats->total_orders, 1) : 0; $itemsPerTransaction = $stats->total_orders > 0 ? round($totalProductsSold / $stats->total_orders, 1) : 0;
@ -556,6 +627,8 @@ public function getProfitMetrics(?string $startDate, ?string $endDate, ?User $us
'profit_margin' => $profitMargin, 'profit_margin' => $profitMargin,
'aov' => $aov, 'aov' => $aov,
'items_per_transaction' => $itemsPerTransaction, 'items_per_transaction' => $itemsPerTransaction,
'payroll_total' => $payrollTotal,
'expense_total' => $expenseTotal,
]; ];
} }

View File

@ -5,6 +5,7 @@
use App\Enums\OrderChannel; use App\Enums\OrderChannel;
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PayrollStatus;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
@ -12,6 +13,7 @@
use App\Models\Expense; use App\Models\Expense;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Order; use App\Models\Order;
use App\Models\Payroll;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -135,12 +137,27 @@ public function getRevenueSummary(?User $user = null): array
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs') ->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->first(); ->first();
$payrollTotal = (int) Payroll::where('status', PayrollStatus::PAID)
->whereDate('paid_at', $today)
->sum('total_amount');
$expenseTotal = (int) Expense::whereDate('created_at', $today)
->sum('amount');
$totalRevenue = (int) $stats->total_revenue;
$totalCogs = (int) $stats->total_cogs;
$gross = $totalRevenue - $totalCogs;
$net = $gross - $payrollTotal - $expenseTotal;
return [ return [
'total_revenue' => (int) $stats->total_revenue, 'total_revenue' => $totalRevenue,
'total_discount' => (int) $stats->total_discount, 'total_discount' => (int) $stats->total_discount,
'total_cogs' => (int) $stats->total_cogs, 'total_cogs' => $totalCogs,
'total_deduction' => (int) $stats->total_deduction, 'total_deduction' => (int) $stats->total_deduction,
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs, 'gross' => $gross,
'net' => $net,
'payroll_total' => $payrollTotal,
'expense_total' => $expenseTotal,
'total_orders' => (int) $stats->total_orders, 'total_orders' => (int) $stats->total_orders,
]; ];
} }

View File

@ -53,6 +53,7 @@ @theme {
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
@ -89,6 +90,7 @@ :root {
--chart-3: oklch(0.666 0.179 58.318); --chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998); --chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201); --chart-5: oklch(0.473 0.137 46.201);
--chart-6: oklch(0.696 0.17 162.48);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
@ -125,6 +127,7 @@ .dark {
--chart-3: oklch(0.666 0.179 58.318); --chart-3: oklch(0.666 0.179 58.318);
--chart-4: oklch(0.555 0.163 48.998); --chart-4: oklch(0.555 0.163 48.998);
--chart-5: oklch(0.473 0.137 46.201); --chart-5: oklch(0.473 0.137 46.201);
--chart-6: oklch(0.696 0.17 162.48);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.769 0.188 70.08); --sidebar-primary: oklch(0.769 0.188 70.08);
@ -183,6 +186,7 @@ @theme inline {
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);

View File

@ -86,17 +86,23 @@ type AnalysisProps = {
total_discount: number; total_discount: number;
total_deduction: number; total_deduction: number;
cogs: number; cogs: number;
gross: number;
net: number; net: number;
payroll_total: number;
expense_total: number;
total_orders: number; total_orders: number;
avg_order: number; avg_order: number;
}; };
monthlyRevenue: Array<{ monthlyRevenue: Array<{
month: string; month: string;
total: number; total: number;
gross: number;
net: number; net: number;
discount: number; discount: number;
deduction: number; deduction: number;
cogs: number; cogs: number;
payroll: number;
expense: number;
}>; }>;
monthlyRevenueByChannel: Array<{ monthlyRevenueByChannel: Array<{
month: string; month: string;
@ -135,6 +141,8 @@ type AnalysisProps = {
profit_margin: number; profit_margin: number;
aov: number; aov: number;
items_per_transaction: number; items_per_transaction: number;
payroll_total: number;
expense_total: number;
}; };
topSuppliers: Array<{ topSuppliers: Array<{
name: string; name: string;
@ -195,12 +203,16 @@ const revenueChartConfig = {
label: 'Total', label: 'Total',
color: 'var(--chart-1)', color: 'var(--chart-1)',
}, },
net: { gross: {
label: 'Bersih', label: 'Keuntungan Kotor',
color: 'var(--chart-2)', color: 'var(--chart-2)',
}, },
net: {
label: 'Keuntungan Bersih',
color: 'var(--chart-6)',
},
deduction: { deduction: {
label: 'Potongan', label: 'Potongan Nego',
color: 'var(--chart-3)', color: 'var(--chart-3)',
}, },
discount: { discount: {
@ -213,7 +225,7 @@ const revenueChartConfig = {
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const revenueKeys = ['total', 'net', 'deduction', 'discount', 'cogs'] as const; const revenueKeys = ['total', 'deduction', 'discount', 'cogs', 'gross', 'net'] as const;
const expenseChartConfig = { const expenseChartConfig = {
total: { total: {
@ -225,7 +237,7 @@ const expenseChartConfig = {
color: 'var(--chart-2)', color: 'var(--chart-2)',
}, },
expense: { expense: {
label: 'Pengeluaran', label: 'Pengeluaran Toko',
color: 'var(--chart-3)', color: 'var(--chart-3)',
}, },
advance: { advance: {
@ -638,9 +650,9 @@ export default function Analysis({
{revenueKeys.filter((key) => { {revenueKeys.filter((key) => {
if (hasAnyRole(['owner', 'developer'])) return true; if (hasAnyRole(['owner', 'developer'])) return true;
if (hasRole('cashier')) return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs'; if (hasRole('cashier')) return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs';
return key === 'total' || key === 'discount' || key === 'deduction' || key === 'net' || key === 'cogs'; return key === 'total' || key === 'discount' || key === 'deduction' || key === 'cogs' || key === 'gross' || key === 'net';
}).map((key) => { }).map((key) => {
const value = key === 'total' ? revenueSummary.total_revenue : key === 'discount' ? revenueSummary.total_discount : key === 'net' ? revenueSummary.net : key === 'cogs' ? revenueSummary.cogs : revenueSummary.total_deduction; const value = key === 'total' ? revenueSummary.total_revenue : key === 'discount' ? revenueSummary.total_discount : key === 'net' ? revenueSummary.net : key === 'gross' ? revenueSummary.gross : key === 'cogs' ? revenueSummary.cogs : revenueSummary.total_deduction;
return ( return (
<button <button
key={key} key={key}
@ -909,7 +921,7 @@ export default function Analysis({
<Card className="py-0" style={{ order: sectionOrder.revenueTrend ?? 99 }}> <Card className="py-0" style={{ order: sectionOrder.revenueTrend ?? 99 }}>
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row"> <CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!"> <div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
<CardTitle>Trend Pendapatan</CardTitle> <CardTitle>Trend Penjualan</CardTitle>
</div> </div>
<div className="flex flex-col sm:flex-row"> <div className="flex flex-col sm:flex-row">
{revenueTrendKeys.map((key) => { {revenueTrendKeys.map((key) => {

View File

@ -51,6 +51,10 @@ type DashboardProps = {
total_discount: number; total_discount: number;
total_cogs: number; total_cogs: number;
total_deduction: number; total_deduction: number;
gross: number;
net: number;
payroll_total: number;
expense_total: number;
total_orders: number; total_orders: number;
}; };
expenseSummary: { expenseSummary: {
@ -341,9 +345,21 @@ export default function Dashboard({
mainValue={`Rp${formatRupiah(revenueSummary.total_revenue)}`} mainValue={`Rp${formatRupiah(revenueSummary.total_revenue)}`}
subLabel={`${revenueSummary.total_orders} transaksi selesai`} subLabel={`${revenueSummary.total_orders} transaksi selesai`}
items={[ items={[
{
label: 'Kotor',
value: `Rp${formatRupiah(revenueSummary.gross)}`,
},
{ {
label: 'Bersih', label: 'Bersih',
value: `Rp${formatRupiah(revenueSummary.total_revenue - revenueSummary.total_cogs)}`, value: `Rp${formatRupiah(revenueSummary.net)}`,
},
{
label: 'Gaji',
value: `Rp${formatRupiah(revenueSummary.payroll_total)}`,
},
{
label: 'Pengeluaran',
value: `Rp${formatRupiah(revenueSummary.expense_total)}`,
}, },
{ {
label: 'Potongan', label: 'Potongan',