Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions frontend/src/api/orders.api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import apiClient from './client';
import type {
FulfillmentStatus,
OrderCreateResponse,
OrderLineResponse,
OrderRequest,
Expand Down Expand Up @@ -42,4 +43,12 @@ export const ordersApi = {

getMyOrders: (signal?: AbortSignal) =>
apiClient.get<OrderResponse[]>('/orders/my', { signal }).then((r) => r.data),

// Fase 5 — SELLER (must own a line in the order) or ADMIN (any order) advances
// fulfillment. Backend accepts only SHIPPED/DELIVERED here; the saga still owns
// CONFIRMED/CANCELLED and refunds arrive via the payment saga.
updateStatus: (orderId: number, status: FulfillmentStatus) =>
apiClient
.patch<OrderResponse>(`/orders/${orderId}/status`, { status })
.then((r) => r.data),
};
12 changes: 11 additions & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,17 @@ export interface CartResponse {

// ── Order ──
export type PaymentMethod = 'PAYPAL' | 'CREDIT_CARD' | 'VISA' | 'MASTER_CARD' | 'BITCOIN';
export type OrderStatus = 'REQUESTED' | 'INVENTORY_RESERVED' | 'CONFIRMED' | 'CANCELLED';
export type OrderStatus =
| 'REQUESTED'
| 'INVENTORY_RESERVED'
| 'CONFIRMED'
| 'CANCELLED'
| 'SHIPPED'
| 'DELIVERED'
| 'REFUNDED';

/** Fase 5: the only statuses a seller/admin may set via PATCH /orders/{id}/status. */
export type FulfillmentStatus = 'SHIPPED' | 'DELIVERED';

export interface OrderRequest {
reference?: string;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/order/OrderStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const STATUS_CONFIG: Record<OrderStatus, { label: string; color: string }> = {
INVENTORY_RESERVED: { label: 'Inventory Reserved', color: 'var(--status-warning)' },
CONFIRMED: { label: 'Confirmed', color: 'var(--status-success)' },
CANCELLED: { label: 'Cancelled', color: 'var(--status-error)' },
// Fase 5 — fulfillment states set by a seller/admin after the saga confirms the order.
SHIPPED: { label: 'Shipped', color: 'var(--status-info)' },
DELIVERED: { label: 'Delivered', color: 'var(--status-success)' },
REFUNDED: { label: 'Refunded', color: 'var(--status-warning)' },
};

export default function OrderStatusBadge({ status }: { status: OrderStatus | string }) {
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/pages/admin/AdminOrdersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { Box, Button, Container, Typography } from '@mui/material';
import { LocalShipping, TaskAlt } from '@mui/icons-material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { ordersApi } from '@api/orders.api';
import { normalizeError } from '@api/client';
import DataTable, { type Column } from '@components/data-display/DataTable';
import { TableSkeleton } from '@components/feedback/LoadingSkeleton';
import ConfirmDialog from '@components/feedback/ConfirmDialog';
import OrderStatusBadge from '@components/order/OrderStatusBadge';
import { useUIStore } from '@stores/ui.store';
import { QUERY_KEYS } from '@utils/constants';
import { formatCurrency } from '@utils/format';
import type { FulfillmentStatus, OrderResponse } from '@api/types';

/**
* Fase 5 — /admin/orders: every order across the platform (GET /orders is ADMIN-only).
* An admin may advance fulfillment on ANY order, unlike a seller who must own a line.
* Only the single legal next step is offered: CONFIRMED → SHIPPED → DELIVERED.
*/
function nextStep(status: string): { label: string; to: FulfillmentStatus } | null {
if (status === 'CONFIRMED') return { label: 'Mark shipped', to: 'SHIPPED' };
if (status === 'SHIPPED') return { label: 'Mark delivered', to: 'DELIVERED' };
return null;
}

export default function AdminOrdersPage() {
const qc = useQueryClient();
const addToast = useUIStore((s) => s.addToast);

const [target, setTarget] = useState<{ order: OrderResponse; to: FulfillmentStatus } | null>(null);

const { data: orders, isLoading } = useQuery({
queryKey: [QUERY_KEYS.ADMIN_ORDERS],
queryFn: ({ signal }) => ordersApi.getAll(signal),
staleTime: 30_000,
});

const statusMut = useMutation({
mutationFn: ({ id, status }: { id: number; status: FulfillmentStatus }) =>
ordersApi.updateStatus(id, status),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: [QUERY_KEYS.ADMIN_ORDERS] });
qc.invalidateQueries({ queryKey: [QUERY_KEYS.SELLER_ORDERS] });
qc.invalidateQueries({ queryKey: [QUERY_KEYS.ORDERS] });
addToast({
message: vars.status === 'SHIPPED' ? 'Order marked as shipped' : 'Order marked as delivered',
variant: 'success',
});
setTarget(null);
},
onError: (err: unknown) => {
addToast({ message: normalizeError(err).message, variant: 'error' });
setTarget(null);
},
});

const COLUMNS: Column<OrderResponse>[] = [
{
key: 'reference',
label: 'Reference',
render: (r) => (
<Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'primary.main' }}>
{r.reference}
</Typography>
),
},
{
key: 'customerId',
label: 'Customer',
render: (r) => {
const name = [r.customerFirstname, r.customerLastname].filter(Boolean).join(' ');
return (
<Box>
{name && <Typography variant="body2">{name}</Typography>}
<Typography variant="caption" sx={{ fontFamily: 'var(--font-mono)', color: 'text.secondary' }}>
{r.customerId}
</Typography>
</Box>
);
},
},
{
key: 'amount',
label: 'Amount',
align: 'right',
render: (r) => (
<Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>
{formatCurrency(r.amount)}
</Typography>
),
},
{ key: 'paymentMethod', label: 'Payment' },
{
key: 'status',
label: 'Status',
render: (r) => <OrderStatusBadge status={r.status} />,
},
{
key: 'fulfillment',
label: 'Fulfillment',
render: (r) => {
const step = nextStep(r.status);
if (!step) return <Typography variant="caption" color="text.secondary">—</Typography>;
return (
<Button
size="small"
startIcon={step.to === 'SHIPPED' ? <LocalShipping /> : <TaskAlt />}
onClick={() => setTarget({ order: r, to: step.to })}
>
{step.label}
</Button>
);
},
},
];

return (
<Container maxWidth="xl" sx={{ py: 2 }}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
<Typography variant="h3" sx={{ fontFamily: 'var(--font-serif)', mb: 1 }}>Orders</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Every order across the platform. Fulfillment can be advanced on any order —
sellers can only advance orders containing their own products.
</Typography>

{isLoading ? (
<TableSkeleton rows={8} cols={6} />
) : (
<DataTable columns={COLUMNS} rows={orders ?? []} />
)}

<ConfirmDialog
open={!!target}
title={target?.to === 'SHIPPED' ? 'Mark order as shipped?' : 'Mark order as delivered?'}
description={
target
? target.to === 'SHIPPED'
? `Order ${target.order.reference} will be marked as shipped and the customer will see the update.`
: `Order ${target.order.reference} will be marked as delivered. This is the final fulfillment step.`
: undefined
}
confirmLabel={target?.to === 'SHIPPED' ? 'Mark shipped' : 'Mark delivered'}
loading={statusMut.isPending}
onConfirm={() => target && statusMut.mutate({ id: target.order.id, status: target.to })}
onCancel={() => setTarget(null)}
/>
</motion.div>
</Container>
);
}
112 changes: 105 additions & 7 deletions frontend/src/pages/seller/OrderManagement.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,83 @@
import { Container, Typography } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Button, Container, Typography } from '@mui/material';
import { LocalShipping, TaskAlt } from '@mui/icons-material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ordersApi } from '@api/orders.api';
import { normalizeError } from '@api/client';
import { QUERY_KEYS, ROUTES } from '@utils/constants';
import DataTable from '@components/data-display/DataTable';
import { TableSkeleton } from '@components/feedback/LoadingSkeleton';
import ConfirmDialog from '@components/feedback/ConfirmDialog';
import OrderStatusBadge from '@components/order/OrderStatusBadge';
import { useUIStore } from '@stores/ui.store';
import { formatCurrency } from '@utils/format';
import type { OrderResponse } from '@api/types';
import type { FulfillmentStatus, OrderResponse } from '@api/types';

/**
* Fase 5 — the seller advances fulfillment on orders containing their own products.
* The backend enforces line-ownership (403 otherwise); the UI only offers the single
* legal next step for the current status: CONFIRMED → SHIPPED → DELIVERED.
*/
function nextStep(status: string): { label: string; to: FulfillmentStatus } | null {
if (status === 'CONFIRMED') return { label: 'Mark shipped', to: 'SHIPPED' };
if (status === 'SHIPPED') return { label: 'Mark delivered', to: 'DELIVERED' };
return null;
}

export default function OrderManagement() {
const navigate = useNavigate();
const qc = useQueryClient();
const addToast = useUIStore((s) => s.addToast);

const [target, setTarget] = useState<{ order: OrderResponse; to: FulfillmentStatus } | null>(null);

const { data: orders, isLoading } = useQuery({
queryKey: [QUERY_KEYS.SELLER_ORDERS],
queryFn: ({ signal }) => ordersApi.getSeller(signal),
staleTime: 30 * 1000,
});

const statusMut = useMutation({
mutationFn: ({ id, status }: { id: number; status: FulfillmentStatus }) =>
ordersApi.updateStatus(id, status),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: [QUERY_KEYS.SELLER_ORDERS] });
// The customer's own order views reflect the same status.
qc.invalidateQueries({ queryKey: [QUERY_KEYS.ORDERS] });
addToast({
message: vars.status === 'SHIPPED' ? 'Order marked as shipped' : 'Order marked as delivered',
variant: 'success',
});
setTarget(null);
},
onError: (err: unknown) => {
addToast({ message: normalizeError(err).message, variant: 'error' });
setTarget(null);
},
});

const COLUMNS = [
{ key: 'reference', label: 'Reference', render: (r: OrderResponse) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'primary.main' }}>{r.reference}</Typography> },
{ key: 'amount', label: 'Amount', align: 'right' as const, render: (r: OrderResponse) => <Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>{formatCurrency(r.amount)}</Typography> },
{
key: 'reference',
label: 'Reference',
render: (r: OrderResponse) => (
<Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'primary.main' }}>
{r.reference}
</Typography>
),
},
{
key: 'amount',
label: 'Amount',
align: 'right' as const,
render: (r: OrderResponse) => (
<Typography sx={{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>
{formatCurrency(r.amount)}
</Typography>
),
},
{ key: 'paymentMethod', label: 'Payment' },
{
key: 'customerId',
Expand All @@ -29,7 +87,31 @@ export default function OrderManagement() {
return name ? (
<Typography variant="body2">{name}</Typography>
) : (
<Typography variant="caption" sx={{ fontFamily: 'var(--font-mono)', color: 'text.secondary' }}>{r.customerId.slice(0, 12)}…</Typography>
<Typography variant="caption" sx={{ fontFamily: 'var(--font-mono)', color: 'text.secondary' }}>
{r.customerId.slice(0, 12)}…
</Typography>
);
},
},
{
key: 'status',
label: 'Status',
render: (r: OrderResponse) => <OrderStatusBadge status={r.status} />,
},
{
key: 'fulfillment',
label: 'Fulfillment',
render: (r: OrderResponse) => {
const step = nextStep(r.status);
if (!step) return <Typography variant="caption" color="text.secondary">—</Typography>;
return (
<Button
size="small"
startIcon={step.to === 'SHIPPED' ? <LocalShipping /> : <TaskAlt />}
onClick={() => setTarget({ order: r, to: step.to })}
>
{step.label}
</Button>
);
},
},
Expand All @@ -40,14 +122,30 @@ export default function OrderManagement() {
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
<Typography variant="h3" sx={{ fontFamily: 'var(--font-serif)', mb: 4 }}>Orders</Typography>
{isLoading ? (
<TableSkeleton rows={8} cols={4} />
<TableSkeleton rows={8} cols={6} />
) : (
<DataTable
columns={COLUMNS}
rows={orders ?? []}
onView={(row) => navigate(ROUTES.SELLER_ORDER_DETAIL(row.id))}
/>
)}

<ConfirmDialog
open={!!target}
title={target?.to === 'SHIPPED' ? 'Mark order as shipped?' : 'Mark order as delivered?'}
description={
target
? target.to === 'SHIPPED'
? `Order ${target.order.reference} will be marked as shipped and the customer will see the update.`
: `Order ${target.order.reference} will be marked as delivered. This is the final fulfillment step.`
: undefined
}
confirmLabel={target?.to === 'SHIPPED' ? 'Mark shipped' : 'Mark delivered'}
loading={statusMut.isPending}
onConfirm={() => target && statusMut.mutate({ id: target.order.id, status: target.to })}
onCancel={() => setTarget(null)}
/>
</motion.div>
</Container>
);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const TenantDetailPage = () => lazy_page(() => import('@pages/admin/TenantDetail
const UsersPage = () => lazy_page(() => import('@pages/admin/UsersPage'));
const AdminProductsPage = () => lazy_page(() => import('@pages/admin/AdminProductsPage'));
const AdminCategoriesPage = () => lazy_page(() => import('@pages/admin/AdminCategoriesPage'));
const AdminOrdersPage = () => lazy_page(() => import('@pages/admin/AdminOrdersPage'));
const PaymentsPage = () => lazy_page(() => import('@pages/admin/PaymentsPage'));
const AnalyticsPage = () => lazy_page(() => import('@pages/admin/AnalyticsPage'));

Expand Down Expand Up @@ -135,6 +136,7 @@ export const router = createBrowserRouter([
{ path: '/admin/users', element: <UsersPage /> },
{ path: '/admin/products', element: <AdminProductsPage /> },
{ path: '/admin/categories', element: <AdminCategoriesPage /> },
{ path: '/admin/orders', element: <AdminOrdersPage /> },
{ path: '/admin/payments', element: <PaymentsPage /> },
{ path: '/admin/analytics', element: <AnalyticsPage /> },
{ path: '/admin/account', element: <AccountSettingsPage /> },
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/routes/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
People,
Inventory2,
Category,
Receipt,
CreditCard,
BarChart,
ManageAccounts,
Expand All @@ -22,6 +23,7 @@ const ADMIN_NAV = [
{ label: 'Users', to: ROUTES.ADMIN_USERS, Icon: People },
{ label: 'Products', to: ROUTES.ADMIN_PRODUCTS, Icon: Inventory2 },
{ label: 'Categories', to: ROUTES.ADMIN_CATEGORIES, Icon: Category },
{ label: 'Orders', to: ROUTES.ADMIN_ORDERS, Icon: Receipt },
{ label: 'Payments', to: ROUTES.ADMIN_PAYMENTS, Icon: CreditCard },
{ label: 'Analytics', to: ROUTES.ADMIN_ANALYTICS, Icon: BarChart },
{ label: 'Account', to: ROUTES.ADMIN_ACCOUNT, Icon: ManageAccounts },
Expand Down
Loading
Loading