feat: add revenue by stock type calculation and update analysis components
This commit is contained in:
parent
731c8aa6da
commit
28f88bb69c
@ -30,6 +30,7 @@ public function index(Request $request): Response
|
|||||||
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
|
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
|
||||||
$rawMaterialStock = $this->service->getRawMaterialStock();
|
$rawMaterialStock = $this->service->getRawMaterialStock();
|
||||||
$productStock = $this->service->getProductStock();
|
$productStock = $this->service->getProductStock();
|
||||||
|
$revenueByStockType = $this->service->getRevenueByStockType($startDate, $endDate, $user);
|
||||||
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate, $user);
|
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate, $user);
|
||||||
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate, $user);
|
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate, $user);
|
||||||
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate, $user);
|
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate, $user);
|
||||||
@ -56,6 +57,7 @@ public function index(Request $request): Response
|
|||||||
'cashOverview' => $cashOverview,
|
'cashOverview' => $cashOverview,
|
||||||
'rawMaterialStock' => $rawMaterialStock,
|
'rawMaterialStock' => $rawMaterialStock,
|
||||||
'productStock' => $productStock,
|
'productStock' => $productStock,
|
||||||
|
'revenueByStockType' => $revenueByStockType,
|
||||||
'revenueSummary' => $revenueSummary,
|
'revenueSummary' => $revenueSummary,
|
||||||
'monthlyRevenue' => $monthlyRevenue,
|
'monthlyRevenue' => $monthlyRevenue,
|
||||||
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,
|
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Enums\PaymentType;
|
use App\Enums\PaymentType;
|
||||||
use App\Enums\PayrollStatus;
|
use App\Enums\PayrollStatus;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Enums\RawMaterialUnit;
|
use App\Enums\RawMaterialUnit;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Models\Attendance;
|
use App\Models\Attendance;
|
||||||
@ -18,6 +19,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\OrderItem;
|
||||||
use App\Models\Payroll;
|
use App\Models\Payroll;
|
||||||
use App\Models\ProductPrice;
|
use App\Models\ProductPrice;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
@ -254,6 +256,64 @@ public function getProductStock(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRevenueByStockType(?string $startDate, ?string $endDate, ?User $user = null): array
|
||||||
|
{
|
||||||
|
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||||
|
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||||
|
|
||||||
|
if ($user && $this->isMarketingUser($user)) {
|
||||||
|
$query->where('orders.marketing_id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stockQualitySubquery = OrderItem::select('order_id')
|
||||||
|
->selectRaw('MIN(stock_quality) as stock_quality')
|
||||||
|
->groupBy('order_id');
|
||||||
|
|
||||||
|
$monthly = (clone $query)
|
||||||
|
->toBase()
|
||||||
|
->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id'))
|
||||||
|
->selectRaw("DATE_FORMAT(orders.created_at, '%b %Y') as month")
|
||||||
|
->selectRaw('oi.stock_quality')
|
||||||
|
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
|
||||||
|
->groupBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(orders.created_at, '%b %Y')"), 'oi.stock_quality')
|
||||||
|
->orderBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')"))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$allMonths = [];
|
||||||
|
$monthlyData = [];
|
||||||
|
foreach ($monthly as $row) {
|
||||||
|
$month = $row->month;
|
||||||
|
if (! array_key_exists($month, $allMonths)) {
|
||||||
|
$allMonths[$month] = $month;
|
||||||
|
$monthlyData[$month] = ['month' => $month, 'good' => 0, 'reject' => 0, 'retail' => 0];
|
||||||
|
}
|
||||||
|
$monthlyData[$month][$row->stock_quality] = (int) $row->total_revenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($allMonths as $month => $_) {
|
||||||
|
$result[] = $monthlyData[$month];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totals = (clone $query)
|
||||||
|
->toBase()
|
||||||
|
->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id'))
|
||||||
|
->selectRaw('oi.stock_quality')
|
||||||
|
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
|
||||||
|
->groupBy('oi.stock_quality')
|
||||||
|
->get()
|
||||||
|
->keyBy('stock_quality');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'monthly' => $result,
|
||||||
|
'totals' => [
|
||||||
|
'good' => (int) ($totals['good']->total_revenue ?? 0),
|
||||||
|
'reject' => (int) ($totals['reject']->total_revenue ?? 0),
|
||||||
|
'retail' => (int) ($totals['retail']->total_revenue ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $user = null): array
|
public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $user = null): array
|
||||||
{
|
{
|
||||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||||
|
|||||||
@ -83,6 +83,19 @@ type AnalysisProps = {
|
|||||||
retail_stock?: number;
|
retail_stock?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
revenueByStockType: {
|
||||||
|
monthly: Array<{
|
||||||
|
month: string;
|
||||||
|
good: number;
|
||||||
|
reject: number;
|
||||||
|
retail: number;
|
||||||
|
}>;
|
||||||
|
totals: {
|
||||||
|
good: number;
|
||||||
|
reject: number;
|
||||||
|
retail: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
revenueSummary: {
|
revenueSummary: {
|
||||||
total_revenue: number;
|
total_revenue: number;
|
||||||
total_discount: number;
|
total_discount: number;
|
||||||
@ -235,6 +248,17 @@ const revenueTrendChartConfig = (() => {
|
|||||||
|
|
||||||
const revenueTrendKeys = ['qty'] as const;
|
const revenueTrendKeys = ['qty'] as const;
|
||||||
|
|
||||||
|
const stockComparisonConfig = (() => {
|
||||||
|
const colors = generateRandomColors(3);
|
||||||
|
return {
|
||||||
|
good: { label: 'Bagus', color: colors[0] },
|
||||||
|
reject: { label: 'Reject', color: colors[1] },
|
||||||
|
retail: { label: 'Ecer', color: colors[2] },
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const stockComparisonKeys = ['good', 'reject', 'retail'] as const;
|
||||||
|
|
||||||
const CHANNEL_COLORS: Record<string, string> = (() => {
|
const CHANNEL_COLORS: Record<string, string> = (() => {
|
||||||
const colors = generateRandomColors(3);
|
const colors = generateRandomColors(3);
|
||||||
return {
|
return {
|
||||||
@ -420,6 +444,7 @@ export default function Analysis({
|
|||||||
cashOverview,
|
cashOverview,
|
||||||
rawMaterialStock,
|
rawMaterialStock,
|
||||||
productStock,
|
productStock,
|
||||||
|
revenueByStockType,
|
||||||
revenueSummary,
|
revenueSummary,
|
||||||
monthlyRevenue,
|
monthlyRevenue,
|
||||||
monthlyRevenueByChannel,
|
monthlyRevenueByChannel,
|
||||||
@ -441,6 +466,7 @@ export default function Analysis({
|
|||||||
const [selectedPreset, setSelectedPreset] = useState('');
|
const [selectedPreset, setSelectedPreset] = useState('');
|
||||||
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
|
||||||
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
|
||||||
|
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
|
||||||
|
|
||||||
const hasActiveFilters = !!startDate || !!endDate;
|
const hasActiveFilters = !!startDate || !!endDate;
|
||||||
|
|
||||||
@ -511,6 +537,8 @@ export default function Analysis({
|
|||||||
];
|
];
|
||||||
}, [monthlyRevenueByChannel]);
|
}, [monthlyRevenueByChannel]);
|
||||||
|
|
||||||
|
const stockComparisonData = useMemo(() => revenueByStockType.monthly, [revenueByStockType]);
|
||||||
|
|
||||||
const peakHour = useMemo(() => {
|
const peakHour = useMemo(() => {
|
||||||
if (busyHours.length === 0) {
|
if (busyHours.length === 0) {
|
||||||
return { hour: '-', orders: 0 };
|
return { hour: '-', orders: 0 };
|
||||||
@ -648,6 +676,70 @@ export default function Analysis({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{can('analysis.product_stock') && (
|
||||||
|
<Card className="py-0" style={{ order: 2 }}>
|
||||||
|
<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!">
|
||||||
|
<CardTitle>Pendapatan per Jenis Stok</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row">
|
||||||
|
{(['good', 'reject', 'retail'] as const).map((key) => {
|
||||||
|
const value = revenueByStockType.totals[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
data-active={activeStockKey === key}
|
||||||
|
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
||||||
|
onClick={() => setActiveStockKey(key)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{stockComparisonConfig[key].label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||||
|
Rp{formatRupiah(value)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 sm:p-6">
|
||||||
|
{stockComparisonData.length > 0 ? (
|
||||||
|
<ChartContainer config={stockComparisonConfig} className="aspect-auto h-[250px] w-full">
|
||||||
|
<BarChart data={stockComparisonData} margin={{ left: 12, right: 12 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[150px]"
|
||||||
|
nameKey="views"
|
||||||
|
formatter={(value, name, item, index) => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||||
|
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{stockComparisonConfig[activeStockKey]?.label ?? name}</span>
|
||||||
|
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||||
|
Rp{Number(value).toLocaleString('id-ID')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar dataKey={activeStockKey} fill={`var(--color-${activeStockKey})`} radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{can('analysis.revenue') && (
|
{can('analysis.revenue') && (
|
||||||
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
|
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user