diff --git a/admin-spa/src/components/nav/DashboardSubmenuBar.tsx b/admin-spa/src/components/nav/DashboardSubmenuBar.tsx index 13f2cdd..10b4164 100644 --- a/admin-spa/src/components/nav/DashboardSubmenuBar.tsx +++ b/admin-spa/src/components/nav/DashboardSubmenuBar.tsx @@ -38,7 +38,7 @@ export default function DashboardSubmenuBar({ items = [], fullscreen = false, he // Fix: Always use exact match to prevent first submenu from being always active const isActive = !!it.path && pathname === it.path; const cls = [ - 'inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap', + 'ui-ctrl inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap', 'focus:outline-none focus:ring-0 focus:shadow-none', isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent hover:text-accent-foreground', ].join(' '); diff --git a/admin-spa/src/components/nav/SubmenuBar.tsx b/admin-spa/src/components/nav/SubmenuBar.tsx index b294c1d..174a403 100644 --- a/admin-spa/src/components/nav/SubmenuBar.tsx +++ b/admin-spa/src/components/nav/SubmenuBar.tsx @@ -25,7 +25,7 @@ export default function SubmenuBar({ items = [], fullscreen = false, headerVisib // Fix: Always use exact match to prevent first submenu from being always active const isActive = !!it.path && pathname === it.path; const cls = [ - 'inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap', + 'ui-ctrl inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 border text-sm whitespace-nowrap', 'focus:outline-none focus:ring-0 focus:shadow-none', isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent hover:text-accent-foreground', ].join(' '); diff --git a/admin-spa/src/routes/More/index.tsx b/admin-spa/src/routes/More/index.tsx index f071dde..79197b1 100644 --- a/admin-spa/src/routes/More/index.tsx +++ b/admin-spa/src/routes/More/index.tsx @@ -50,7 +50,7 @@ export default function MorePage() { }; return ( -
+
{/* Remove inline header - use PageHeader component instead */}

diff --git a/admin-spa/src/routes/Orders/components/FilterBottomSheet.tsx b/admin-spa/src/routes/Orders/components/FilterBottomSheet.tsx new file mode 100644 index 0000000..711431d --- /dev/null +++ b/admin-spa/src/routes/Orders/components/FilterBottomSheet.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { X } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import DateRange from '@/components/filters/DateRange'; +import OrderBy from '@/components/filters/OrderBy'; + +interface FilterBottomSheetProps { + open: boolean; + onClose: () => void; + filters: { + status?: string; + dateStart?: string; + dateEnd?: string; + orderby: 'date' | 'id' | 'modified' | 'total'; + order: 'asc' | 'desc'; + }; + onFiltersChange: (filters: any) => void; + onReset: () => void; + activeFiltersCount: number; +} + +export function FilterBottomSheet({ + open, + onClose, + filters, + onFiltersChange, + onReset, + activeFiltersCount, +}: FilterBottomSheetProps) { + if (!open) return null; + + const hasActiveFilters = activeFiltersCount > 0; + + return ( + <> + {/* Backdrop */} +

+ + {/* Bottom Sheet */} +
+ {/* Drag Handle */} +
+
+
+ + {/* Header */} +
+

{__('Filters')}

+ +
+ + {/* Content */} +
+ {/* Status Filter */} +
+ + +
+ + {/* Date Range Filter */} +
+ + { + onFiltersChange({ + ...filters, + dateStart: v.date_start, + dateEnd: v.date_end, + }); + }} + /> +
+ + {/* Sort Order Filter */} +
+ + { + onFiltersChange({ + ...filters, + orderby: (v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total', + order: (v.order ?? 'desc') as 'asc' | 'desc', + }); + }} + /> +
+
+ + {/* Footer */} +
+ {hasActiveFilters && ( + + )} + +
+
+ + ); +} diff --git a/admin-spa/src/routes/Orders/components/OrderCard.tsx b/admin-spa/src/routes/Orders/components/OrderCard.tsx new file mode 100644 index 0000000..7dce108 --- /dev/null +++ b/admin-spa/src/routes/Orders/components/OrderCard.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, Package } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { formatMoney } from '@/lib/currency'; +import { formatRelativeOrDate } from '@/lib/dates'; +import { Checkbox } from '@/components/ui/checkbox'; + +const statusStyle: Record = { + pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + completed: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300', + 'on-hold': 'bg-slate-200 text-slate-800 dark:bg-slate-800 dark:text-slate-300', + cancelled: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300', + refunded: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', + failed: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300', +}; + +interface OrderCardProps { + order: any; + selected?: boolean; + onSelect?: (id: number) => void; + currencyConfig: any; +} + +export function OrderCard({ order, selected, onSelect, currencyConfig }: OrderCardProps) { + const statusClass = statusStyle[order.status?.toLowerCase()] || 'bg-slate-100 text-slate-800'; + + return ( + +
+ {/* Checkbox */} + {onSelect && ( +
{ + e.preventDefault(); + e.stopPropagation(); + onSelect(order.id); + }} + className="pt-1" + > + +
+ )} + + {/* Icon */} +
+ +
+ + {/* Content */} +
+ {/* Order Number & Status */} +
+

#{order.number}

+ + {order.status || 'unknown'} + +
+ + {/* Customer */} +
+ {order.customer || __('Guest')} +
+ + {/* Items Brief */} + {order.items_brief && ( +
+ {order.items_count} {order.items_count === 1 ? __('item') : __('items')} · {order.items_brief} +
+ )} + + {/* Date & Total */} +
+ + {formatRelativeOrDate(order.date_ts)} + + + {formatMoney(order.total, { + currency: order.currency || currencyConfig.currency, + symbol: order.currency_symbol || currencyConfig.symbol, + thousandSep: currencyConfig.thousand_sep, + decimalSep: currencyConfig.decimal_sep, + position: currencyConfig.position, + decimals: currencyConfig.decimals, + })} + +
+
+ + {/* Chevron */} + +
+ + ); +} diff --git a/admin-spa/src/routes/Orders/components/SearchBar.tsx b/admin-spa/src/routes/Orders/components/SearchBar.tsx new file mode 100644 index 0000000..201bbda --- /dev/null +++ b/admin-spa/src/routes/Orders/components/SearchBar.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Search, SlidersHorizontal } from 'lucide-react'; +import { __ } from '@/lib/i18n'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + onFilterClick: () => void; + filterCount?: number; +} + +export function SearchBar({ value, onChange, onFilterClick, filterCount = 0 }: SearchBarProps) { + return ( +
+ {/* Search Input */} +
+ + onChange(e.target.value)} + placeholder={__('Search orders...')} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* Filter Button */} + +
+ ); +} diff --git a/admin-spa/src/routes/Orders/index.old.tsx b/admin-spa/src/routes/Orders/index.old.tsx new file mode 100644 index 0000000..085f412 --- /dev/null +++ b/admin-spa/src/routes/Orders/index.old.tsx @@ -0,0 +1,478 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { Filter, PackageOpen, Trash2 } from 'lucide-react'; +import { ErrorCard } from '@/components/ErrorCard'; +import { getPageLoadErrorMessage } from '@/lib/errorHandling'; +import { __ } from '@/lib/i18n'; +import { useFABConfig } from '@/hooks/useFABConfig'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { toast } from 'sonner'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { formatRelativeOrDate } from "@/lib/dates"; +import { Link, useNavigate } from 'react-router-dom'; + +function ItemsCell({ row }: { row: any }) { + const count: number = typeof row.items_count === 'number' ? row.items_count : 0; + const brief: string = row.items_brief || ''; + const linesTotal: number | undefined = typeof row.lines_total === 'number' ? row.lines_total : undefined; + const linesPreview: number | undefined = typeof row.lines_preview === 'number' ? row.lines_preview : undefined; + const extra = linesTotal && linesPreview ? Math.max(0, linesTotal - linesPreview) : 0; + + const label = `${count || '—'} item${count === 1 ? '' : 's'}`; + const inline = brief + (extra > 0 ? ` +${extra} more` : ''); + + return ( +
+ + + + {label} + {inline ? <> · {inline} : null} + + + +
{label}
+
+ {row.items_full || brief || 'No items'} +
+
+
+
+ ); +} +import { Skeleton } from '@/components/ui/skeleton'; +import { formatMoney, getStoreCurrency } from '@/lib/currency'; +import DateRange from '@/components/filters/DateRange'; +import OrderBy from '@/components/filters/OrderBy'; +import { setQuery, getQuery } from '@/lib/query-params'; + +const statusStyle: Record = { + pending: 'bg-amber-100 text-amber-800', + processing: 'bg-blue-100 text-blue-800', + completed: 'bg-emerald-100 text-emerald-800', + 'on-hold': 'bg-slate-200 text-slate-800', + cancelled: 'bg-zinc-200 text-zinc-800', + refunded: 'bg-purple-100 text-purple-800', + failed: 'bg-rose-100 text-rose-800', +}; + +function StatusBadge({ value }: { value?: string }) { + const v = (value || '').toLowerCase(); + const cls = statusStyle[v] || 'bg-slate-100 text-slate-800'; + return ( + {v || 'unknown'} + ); +} + +export default function Orders() { + useFABConfig('orders'); // Add FAB for creating orders + const initial = getQuery(); + const [page, setPage] = useState(Number(initial.page ?? 1) || 1); + const [status, setStatus] = useState(initial.status || undefined); + const [dateStart, setDateStart] = useState(initial.date_start || undefined); + const [dateEnd, setDateEnd] = useState(initial.date_end || undefined); + const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date'); + const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc'); + const [selectedIds, setSelectedIds] = useState([]); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const perPage = 20; + + React.useEffect(() => { + setQuery({ page, status, date_start: dateStart, date_end: dateEnd, orderby, order }); + }, [page, status, dateStart, dateEnd, orderby, order]); + + const q = useQuery({ + queryKey: ['orders', { page, perPage, status, dateStart, dateEnd, orderby, order }], + queryFn: () => api.get('/orders', { + page, per_page: perPage, + status, + date_start: dateStart, + date_end: dateEnd, + orderby, + order, + }), + placeholderData: keepPreviousData, + }); + + const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number }; + const nav = useNavigate(); + const store = getStoreCurrency(); + + // Bulk delete mutation + const deleteMutation = useMutation({ + mutationFn: async (ids: number[]) => { + const results = await Promise.allSettled( + ids.map(id => api.del(`/orders/${id}`)) + ); + const failed = results.filter(r => r.status === 'rejected').length; + return { total: ids.length, failed }; + }, + onSuccess: (result) => { + const { total, failed } = result; + if (failed === 0) { + toast.success(__('Orders deleted successfully')); + } else if (failed < total) { + toast.warning(__(`${total - failed} orders deleted, ${failed} failed`)); + } else { + toast.error(__('Failed to delete orders')); + } + setSelectedIds([]); + setShowDeleteDialog(false); + q.refetch(); + }, + onError: () => { + toast.error(__('Failed to delete orders')); + setShowDeleteDialog(false); + }, + }); + + // Checkbox handlers + const allIds = data?.rows?.map(r => r.id) || []; + const allSelected = allIds.length > 0 && selectedIds.length === allIds.length; + const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length; + + const toggleAll = () => { + if (allSelected) { + setSelectedIds([]); + } else { + setSelectedIds(allIds); + } + }; + + const toggleRow = (id: number) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + ); + }; + + const handleDeleteClick = () => { + if (selectedIds.length > 0) { + setShowDeleteDialog(true); + } + }; + + const confirmDelete = () => { + deleteMutation.mutate(selectedIds); + }; + + return ( +
+
+
+ + {selectedIds.length > 0 && ( + + )} + {/* Mobile: condensed Filters button with HoverCard */} +
+ + + + + +
+ +
+ + { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }} + /> + + { + setPage(1); + setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total'); + setOrder((v.order ?? 'desc') as 'asc' | 'desc'); + }} + /> + +
+ {(status || dateStart || dateEnd || orderby !== 'date' || order !== 'desc') ? ( + + ) : } + {q.isFetching && {__('Loading…')}} +
+
+
+
+
+ + + {/* Desktop: full inline filters */} +
+
+ + + + { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }} + /> + + { + setPage(1); + setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total'); + setOrder((v.order ?? 'desc') as 'asc' | 'desc'); + }} + /> + +
+ {status && ( + + )} + {q.isFetching && {__('Loading…')}} +
+
+ +
+ {q.isLoading && ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ )} + + {q.isError && ( + q.refetch()} + /> + )} + + {!q.isLoading && !q.isError && ( + + + + + + + + + + + + + + + {data?.rows?.map((row) => ( + + + + + + + + + + + ))} + + {(!data || data.rows.length === 0) && ( + + + + )} + +
+ + {__('Order')}{__('Date')}{__('Customer')}{__('Items')}{__('Status')}{__('Total')}{__('Actions')}
+ toggleRow(row.id)} + aria-label={__('Select order')} + /> + + #{row.number} + + + {formatRelativeOrDate(row.date_ts)} + + {row.customer || '—'} + + + {formatMoney(row.total, { + currency: row.currency || store.currency, + symbol: row.currency_symbol || store.symbol, + thousandSep: store.thousand_sep, + decimalSep: store.decimal_sep, + position: store.position, + decimals: store.decimals, + })} + + {__('Open')} + {__('Edit')} +
+
+ +
{__('No orders found')}
+ {status ? ( +

{__('Try adjusting filters.')}

+ ) : ( +

{__('Once you receive orders, they\'ll show up here.')}

+ )} +
+
+ )} +
+ +
+ +
{__('Page')} {page}
+ +
+ + {/* Delete Confirmation Dialog */} + + + + {__('Delete Orders')} + + {__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('order') : __('orders')}? +
+ {__('This action cannot be undone.')} +
+
+ + setShowDeleteDialog(false)} + disabled={deleteMutation.isPending} + > + {__('Cancel')} + + + {deleteMutation.isPending ? __('Deleting...') : __('Delete')} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/admin-spa/src/routes/Orders/index.tsx b/admin-spa/src/routes/Orders/index.tsx index 085f412..e845217 100644 --- a/admin-spa/src/routes/Orders/index.tsx +++ b/admin-spa/src/routes/Orders/index.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query'; import { api } from '@/lib/api'; -import { Filter, PackageOpen, Trash2 } from 'lucide-react'; +import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react'; import { ErrorCard } from '@/components/ErrorCard'; import { getPageLoadErrorMessage } from '@/lib/errorHandling'; import { __ } from '@/lib/i18n'; @@ -25,12 +25,19 @@ import { SelectContent, SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { formatRelativeOrDate } from "@/lib/dates"; import { Link, useNavigate } from 'react-router-dom'; +import { Skeleton } from '@/components/ui/skeleton'; +import { formatMoney, getStoreCurrency } from '@/lib/currency'; +import DateRange from '@/components/filters/DateRange'; +import OrderBy from '@/components/filters/OrderBy'; +import { setQuery, getQuery } from '@/lib/query-params'; +import { OrderCard } from './components/OrderCard'; +import { FilterBottomSheet } from './components/FilterBottomSheet'; +import { SearchBar } from './components/SearchBar'; function ItemsCell({ row }: { row: any }) { const count: number = typeof row.items_count === 'number' ? row.items_count : 0; @@ -61,11 +68,6 @@ function ItemsCell({ row }: { row: any }) {
); } -import { Skeleton } from '@/components/ui/skeleton'; -import { formatMoney, getStoreCurrency } from '@/lib/currency'; -import DateRange from '@/components/filters/DateRange'; -import OrderBy from '@/components/filters/OrderBy'; -import { setQuery, getQuery } from '@/lib/query-params'; const statusStyle: Record = { pending: 'bg-amber-100 text-amber-800', @@ -96,6 +98,9 @@ export default function Orders() { const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc'); const [selectedIds, setSelectedIds] = useState([]); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [filterSheetOpen, setFilterSheetOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); const perPage = 20; React.useEffect(() => { @@ -119,6 +124,35 @@ export default function Orders() { const nav = useNavigate(); const store = getStoreCurrency(); + // Pull to refresh + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await q.refetch(); + setTimeout(() => setIsRefreshing(false), 500); + }, [q]); + + // Filter orders by search query + const filteredOrders = React.useMemo(() => { + if (!data?.rows) return []; + if (!searchQuery.trim()) return data.rows; + + const query = searchQuery.toLowerCase(); + return data.rows.filter(order => + order.number?.toString().includes(query) || + order.customer?.toLowerCase().includes(query) || + order.status?.toLowerCase().includes(query) + ); + }, [data?.rows, searchQuery]); + + // Count active filters + const activeFiltersCount = React.useMemo(() => { + let count = 0; + if (status) count++; + if (dateStart || dateEnd) count++; + if (orderby !== 'date' || order !== 'desc') count++; + return count; + }, [status, dateStart, dateEnd, orderby, order]); + // Bulk delete mutation const deleteMutation = useMutation({ mutationFn: async (ids: number[]) => { @@ -148,7 +182,7 @@ export default function Orders() { }); // Checkbox handlers - const allIds = data?.rows?.map(r => r.id) || []; + const allIds = filteredOrders.map(r => r.id) || []; const allSelected = allIds.length > 0 && selectedIds.length === allIds.length; const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length; @@ -176,100 +210,59 @@ export default function Orders() { deleteMutation.mutate(selectedIds); }; + const handleFiltersChange = (newFilters: any) => { + setPage(1); + setStatus(newFilters.status); + setDateStart(newFilters.dateStart); + setDateEnd(newFilters.dateEnd); + setOrderby(newFilters.orderby); + setOrder(newFilters.order); + }; + + const handleResetFilters = () => { + setStatus(undefined); + setDateStart(undefined); + setDateEnd(undefined); + setOrderby('date'); + setOrder('desc'); + setPage(1); + }; + return ( -
-
-
- - {selectedIds.length > 0 && ( - - )} - {/* Mobile: condensed Filters button with HoverCard */} -
- - - - - -
- -
- - { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }} - /> - - { - setPage(1); - setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total'); - setOrder((v.order ?? 'desc') as 'asc' | 'desc'); - }} - /> - -
- {(status || dateStart || dateEnd || orderby !== 'date' || order !== 'desc') ? ( - - ) : } - {q.isFetching && {__('Loading…')}} -
-
-
+ {selectedIds.length > 0 && ( + + )}
-
- - {/* Desktop: full inline filters */} -
-
+