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(),
|
...$request->validatedWithDefaults(),
|
||||||
filters: $filters,
|
filters: $filters,
|
||||||
),
|
),
|
||||||
|
'summary' => $this->service->getSummary($filters),
|
||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'filterOptions' => $this->service->getFilterOptions(),
|
'filterOptions' => $this->service->getFilterOptions(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -89,6 +89,30 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
|||||||
return $paginator;
|
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
|
public function getFilterOptions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Enums\ProductStockQuality;
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
trait HasStockAdjustment
|
trait HasStockAdjustment
|
||||||
{
|
{
|
||||||
@ -18,6 +19,17 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
|
|||||||
if ($sign > 0) {
|
if ($sign > 0) {
|
||||||
$model->increment($field, $quantity);
|
$model->increment($field, $quantity);
|
||||||
} else {
|
} 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);
|
$model->decrement($field, $quantity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import {
|
|||||||
import type { Transaction } from './columns';
|
import type { Transaction } from './columns';
|
||||||
import { TransactionCardRow } from './transaction-card';
|
import { TransactionCardRow } from './transaction-card';
|
||||||
import { TransactionItemSubRow } from './transaction-sub-row';
|
import { TransactionItemSubRow } from './transaction-sub-row';
|
||||||
|
import { TransactionSummaryCard } from './transaction-summary-card';
|
||||||
|
|
||||||
type FilterOption = {
|
type FilterOption = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -52,6 +53,13 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
summary: {
|
||||||
|
total_orders: number;
|
||||||
|
total_subtotal: number;
|
||||||
|
total_discount: number;
|
||||||
|
total_amount: number;
|
||||||
|
net_total: number;
|
||||||
|
};
|
||||||
filters: {
|
filters: {
|
||||||
status?: string;
|
status?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
@ -71,6 +79,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function TransactionIndex({
|
export default function TransactionIndex({
|
||||||
transactions,
|
transactions,
|
||||||
|
summary,
|
||||||
filters,
|
filters,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -333,6 +342,8 @@ export default function TransactionIndex({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TransactionSummaryCard summary={summary} />
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={transactions.data}
|
data={transactions.data}
|
||||||
getItemKey={(t) => t.id}
|
getItemKey={(t) => t.id}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { RowActions } from '@/components/row-actions';
|
import { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -6,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||||
import type { Transaction } from './columns';
|
import type { Transaction } from './columns';
|
||||||
|
|
||||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
@ -70,7 +70,7 @@ export function TransactionCardRow({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{index}.
|
{index}.
|
||||||
</span>
|
</span>
|
||||||
<h3 className="truncate font-medium">
|
<h3 className="font-medium">
|
||||||
{transaction.order_number}
|
{transaction.order_number}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
@ -113,7 +113,7 @@ export function TransactionCardRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{transaction.notes && (
|
{transaction.notes && (
|
||||||
<span className="max-w-[200px] truncate">
|
<span className="max-w-[200px]">
|
||||||
{transaction.notes}
|
{transaction.notes}
|
||||||
</span>
|
</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