feat: add transaction summary feature with detailed metrics display
This commit is contained in:
parent
8399de9d2e
commit
1d866d1b94
@ -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(),
|
||||
]);
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user