feat: add transaction summary feature with detailed metrics display

This commit is contained in:
Yoga Pangestu 2026-08-06 14:25:44 +07:00
parent 8399de9d2e
commit 1d866d1b94
6 changed files with 141 additions and 3 deletions

View File

@ -26,6 +26,7 @@ public function index(PaginatedRequest $request): Response
...$request->validatedWithDefaults(),
filters: $filters,
),
'summary' => $this->service->getSummary($filters),
'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(),
]);

View File

@ -89,6 +89,30 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getSummary(array $filters = []): array
{
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn($q, $createdById) => $q->where('created_by_id', $createdById))
->first();
return [
'total_orders' => $query->total_orders,
'total_subtotal' => $query->total_subtotal,
'total_discount' => $query->total_discount,
'total_amount' => $query->total_amount,
'net_total' => $query->total_amount,
];
}
public function getFilterOptions(): array
{
return [

View File

@ -5,6 +5,7 @@
use App\Enums\ProductStockQuality;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
trait HasStockAdjustment
{
@ -18,6 +19,17 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
if ($sign > 0) {
$model->increment($field, $quantity);
} else {
if ($model->{$field} < $quantity) {
$label = match ($field) {
'stock' => 'stok bagus',
'reject_stock' => 'stok reject',
'retail_stock' => 'stok ecer',
default => $field,
};
throw ValidationException::withMessages([
'stock' => "Stok {$label} tidak mencukupi. Tersedia: {$model->{$field}}, dibutuhkan: {$quantity}.",
]);
}
$model->decrement($field, $quantity);
}
}

View File

@ -33,6 +33,7 @@ import {
import type { Transaction } from './columns';
import { TransactionCardRow } from './transaction-card';
import { TransactionItemSubRow } from './transaction-sub-row';
import { TransactionSummaryCard } from './transaction-summary-card';
type FilterOption = {
id: number;
@ -52,6 +53,13 @@ type Props = {
per_page: number;
total: number;
};
summary: {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
net_total: number;
};
filters: {
status?: string;
channel?: string;
@ -71,6 +79,7 @@ type Props = {
export default function TransactionIndex({
transactions,
summary,
filters,
filterOptions,
}: Props) {
@ -333,6 +342,8 @@ export default function TransactionIndex({
}
/>
<TransactionSummaryCard summary={summary} />
<CardTable
data={transactions.data}
getItemKey={(t) => t.id}

View File

@ -1,4 +1,3 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -6,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/hooks/use-can';
import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import type { Transaction } from './columns';
const STATUS_BADGE_CLASSES: Record<string, string> = {
@ -70,7 +70,7 @@ export function TransactionCardRow({
<span className="text-xs text-muted-foreground">
{index}.
</span>
<h3 className="truncate font-medium">
<h3 className="font-medium">
{transaction.order_number}
</h3>
<Badge
@ -113,7 +113,7 @@ export function TransactionCardRow({
</span>
)}
{transaction.notes && (
<span className="max-w-[200px] truncate">
<span className="max-w-[200px]">
{transaction.notes}
</span>
)}

View File

@ -0,0 +1,90 @@
import { Card, CardContent } from '@/components/ui/card';
import { formatCurrency } from '@/lib/utils';
import {
Banknote,
CircleDollarSign,
FileText,
Percent,
TrendingUp,
} from 'lucide-react';
type Summary = {
total_orders: number;
total_subtotal: number;
total_discount: number;
total_amount: number;
net_total: number;
};
type TransactionSummaryCardProps = {
summary: Summary;
};
const summaryItems = [
{
key: 'total_orders',
label: 'Total Pesanan',
icon: FileText,
color: 'bg-blue-100',
iconColor: 'text-blue-600',
format: (value: number) => value.toLocaleString('id-ID'),
},
{
key: 'total_subtotal',
label: 'Subtotal',
icon: Banknote,
color: 'bg-emerald-100',
iconColor: 'text-emerald-600',
format: formatCurrency,
},
{
key: 'total_discount',
label: 'Diskon',
icon: Percent,
color: 'bg-amber-100',
iconColor: 'text-amber-600',
format: formatCurrency,
},
{
key: 'total_amount',
label: 'Total Uang',
icon: CircleDollarSign,
color: 'bg-purple-100',
iconColor: 'text-purple-600',
format: formatCurrency,
},
{
key: 'net_total',
label: 'Total Bersih',
icon: TrendingUp,
color: 'bg-sky-100',
iconColor: 'text-sky-600',
format: formatCurrency,
},
] as const;
export function TransactionSummaryCard({ summary }: TransactionSummaryCardProps) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryItems.map((item) => (
<Card key={item.key}>
<CardContent className="flex items-center gap-3 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${item.color}`}
>
<item.icon className={`h-5 w-5 ${item.iconColor}`} />
</div>
<div>
<p className="text-xs text-muted-foreground">
{item.label}
</p>
<p className="text-lg font-bold">
{item.format(summary[item.key] as number)}
</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}