340 lines
18 KiB
TypeScript
340 lines
18 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { FaShoppingCart, FaPlus, FaSync, FaChevronDown, FaChevronUp, FaTimes, FaSearch, FaFileExport, FaEye, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa';
|
||
import { toast, ToastContainer } from 'react-toastify';
|
||
import 'react-toastify/dist/ReactToastify.css';
|
||
import salesOrderService, { SalesOrder } from '../services/salesOrderService';
|
||
import DynamicExportModal from '../components/DynamicExportModal';
|
||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
function buildSalesOrderExportFilters(f: { search: string; status: string; project: string }) {
|
||
const filters: any[] = [];
|
||
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||
return filters;
|
||
}
|
||
|
||
function getStatusStyle(so: SalesOrder) {
|
||
if (so.docstatus === 2) return 'bg-red-100 text-red-700';
|
||
if (so.docstatus === 1) {
|
||
if (so.billing_status === 'Fully Billed') return 'bg-green-100 text-green-700';
|
||
if (so.delivery_status === 'Fully Delivered') return 'bg-blue-100 text-blue-700';
|
||
return 'bg-blue-100 text-blue-700';
|
||
}
|
||
return 'bg-yellow-100 text-yellow-800';
|
||
}
|
||
|
||
function getStatusLabel(so: SalesOrder) {
|
||
if (so.docstatus === 2) return 'Cancelled';
|
||
if (so.docstatus === 1) return so.status || 'Submitted';
|
||
return 'Draft';
|
||
}
|
||
|
||
const SalesOrderList: React.FC = () => {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [orders, setOrders] = useState<SalesOrder[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(0);
|
||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||
|
||
const initialProject = searchParams.get('project')?.trim() || '';
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [projectFilter, setProjectFilter] = useState(initialProject);
|
||
const [applied, setApplied] = useState({ search: '', status: '', project: initialProject });
|
||
const [showExportModal, setShowExportModal] = useState(false);
|
||
const didInitUrlSync = useRef(false);
|
||
const searchDebounceRef = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
const p = searchParams.get('project')?.trim() || '';
|
||
setProjectFilter(p);
|
||
setApplied(a => {
|
||
if (a.project === p) return a;
|
||
setPage(0);
|
||
return { ...a, project: p };
|
||
});
|
||
}, [searchParams]);
|
||
|
||
const load = useCallback(async (off: number, f: typeof applied) => {
|
||
setLoading(true);
|
||
try {
|
||
const filters: any[] = [];
|
||
if (f.search) filters.push(['Sales Order', 'name', 'like', `%${f.search}%`]);
|
||
if (f.project) filters.push(['Sales Order', 'project', '=', f.project]);
|
||
if (f.status === 'Draft') filters.push(['Sales Order', 'docstatus', '=', 0]);
|
||
if (f.status === 'Submitted') filters.push(['Sales Order', 'docstatus', '=', 1]);
|
||
if (f.status === 'Cancelled') filters.push(['Sales Order', 'docstatus', '=', 2]);
|
||
const [rows, cnt] = await Promise.all([
|
||
salesOrderService.getSalesOrders({ filters, limit_start: off, limit_page_length: PAGE_SIZE }),
|
||
salesOrderService.getSalesOrderCount(filters),
|
||
]);
|
||
setOrders(rows);
|
||
setTotal(cnt);
|
||
} catch (e: any) {
|
||
toast.error(e.message || 'Failed to load');
|
||
} finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { load(0, applied); }, [load, applied]);
|
||
|
||
const selectionResetKey = useMemo(
|
||
() => `${page}|${applied.search}|${applied.status}|${applied.project}`,
|
||
[page, applied.search, applied.status, applied.project],
|
||
);
|
||
const {
|
||
selectedRows,
|
||
toggleRow,
|
||
toggleAllOnPage,
|
||
allOnPageSelected,
|
||
someOnPageSelected,
|
||
} = useListPageSelection(orders, selectionResetKey);
|
||
|
||
// Auto-apply filters (matches other lists; no explicit Apply button)
|
||
useEffect(() => {
|
||
if (!didInitUrlSync.current) {
|
||
didInitUrlSync.current = true;
|
||
return;
|
||
}
|
||
const nextApplied = { search: applied.search, status: statusFilter, project: projectFilter.trim() };
|
||
setApplied(nextApplied);
|
||
setPage(0);
|
||
setSearchParams(prev => {
|
||
const n = new URLSearchParams(prev);
|
||
if (nextApplied.project) n.set('project', nextApplied.project); else n.delete('project');
|
||
return n;
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [statusFilter, projectFilter]);
|
||
|
||
useEffect(() => {
|
||
if (!didInitUrlSync.current) return;
|
||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||
searchDebounceRef.current = window.setTimeout(() => {
|
||
const nextApplied = { search: searchQuery, status: statusFilter, project: projectFilter.trim() };
|
||
setApplied(nextApplied);
|
||
setPage(0);
|
||
}, 450);
|
||
return () => {
|
||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||
};
|
||
}, [searchQuery, statusFilter, projectFilter]);
|
||
const clear = () => {
|
||
setSearchQuery(''); setStatusFilter(''); setProjectFilter('');
|
||
setApplied({ search: '', status: '', project: '' }); setPage(0);
|
||
setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; });
|
||
};
|
||
const hasActive = !!(applied.search || applied.status || applied.project);
|
||
|
||
const goPage = (p: number) => { setPage(p); load(p * PAGE_SIZE, applied); };
|
||
const handleView = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}`);
|
||
const handleEdit = (name: string) => navigate(`/sales-orders/${encodeURIComponent(name)}?edit=1`);
|
||
const handleDuplicate = (name: string) => navigate(`/sales-orders/new?duplicate=${encodeURIComponent(name)}`);
|
||
|
||
const fetchAllForExport = useCallback(
|
||
() =>
|
||
fetchAllRowsForExport({
|
||
doctype: 'Sales Order',
|
||
filters: buildSalesOrderExportFilters(applied),
|
||
orderBy: 'modified desc',
|
||
}),
|
||
[applied],
|
||
);
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<ToastContainer position="top-right" autoClose={3000} />
|
||
|
||
<div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center">
|
||
<FaShoppingCart className="text-white text-base" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Sales Orders</h1>
|
||
<p className="text-xs text-gray-500">{total} total</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button onClick={() => load(page * PAGE_SIZE, applied)} className="p-2 text-gray-500 hover:text-blue-600 border border-gray-200 rounded-lg">
|
||
<FaSync size={13} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowExportModal(true)}
|
||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all text-sm font-medium disabled:opacity-50"
|
||
disabled={total === 0 && selectedRows.size === 0}
|
||
>
|
||
<FaFileExport /> {t('listPages.export')}
|
||
{selectedRows.size > 0 && (
|
||
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const qs = applied.project ? `?project=${encodeURIComponent(applied.project)}` : '';
|
||
navigate(`/sales-orders/new${qs}`);
|
||
}}
|
||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||
>
|
||
<FaPlus size={11} /> New Order
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<DynamicExportModal
|
||
isOpen={showExportModal}
|
||
onClose={() => setShowExportModal(false)}
|
||
doctype="Sales Order"
|
||
selectedCount={selectedRows.size}
|
||
pageCount={orders.length}
|
||
totalCount={total}
|
||
pageData={orders}
|
||
selectedRows={selectedRows}
|
||
rowKey="name"
|
||
onFetchAll={fetchAllForExport}
|
||
fileNamePrefix="sales_orders"
|
||
/>
|
||
|
||
{/* Filter Panel */}
|
||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl mb-5 overflow-hidden">
|
||
<button onClick={() => setFiltersOpen(o => !o)} className="w-full flex items-center justify-between px-4 py-3 bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 text-white">
|
||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||
<FaSearch size={12} /> Filters
|
||
{hasActive && <span className="bg-white/30 text-white text-xs px-2 py-0.5 rounded-full">Active</span>}
|
||
</div>
|
||
{filtersOpen ? <FaChevronUp size={11} /> : <FaChevronDown size={11} />}
|
||
</button>
|
||
|
||
{hasActive && (
|
||
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 flex flex-wrap gap-2 items-center border-b border-blue-100 dark:border-blue-800">
|
||
{applied.search && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">ID: {applied.search}<button type="button" onClick={() => { setSearchQuery(''); setApplied(a => ({ ...a, search: '' })); }}><FaTimes size={9} /></button></span>}
|
||
{applied.project && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Project: {applied.project}<button type="button" onClick={() => { setProjectFilter(''); setApplied(a => ({ ...a, project: '' })); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); return n; }); }}><FaTimes size={9} /></button></span>}
|
||
{applied.status && <span className="flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 px-2 py-1 rounded-full">Status: {applied.status}<button type="button" onClick={() => { setStatusFilter(''); setApplied(a => ({ ...a, status: '' })); }}><FaTimes size={9} /></button></span>}
|
||
<button type="button" onClick={clear} className="text-xs text-blue-600 hover:underline ml-auto">Clear All</button>
|
||
</div>
|
||
)}
|
||
|
||
{filtersOpen && (
|
||
<div className="px-4 py-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Order ID</label>
|
||
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Project</label>
|
||
<input value={projectFilter} onChange={e => setProjectFilter(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Project name…" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Status</label>
|
||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-400">
|
||
<option value="">All</option>
|
||
<option value="Draft">Draft</option>
|
||
<option value="Submitted">Submitted</option>
|
||
<option value="Cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
|
||
<th className="w-10 px-4 py-3 text-left">
|
||
<button
|
||
type="button"
|
||
onClick={toggleAllOnPage}
|
||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||
aria-label="Select all on page"
|
||
>
|
||
{allOnPageSelected
|
||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||
: someOnPageSelected
|
||
? (
|
||
<div className="relative inline-block">
|
||
<FaSquare size={18} />
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="w-2 h-0.5 bg-current" />
|
||
</div>
|
||
</div>
|
||
)
|
||
: <FaSquare size={18} />}
|
||
</button>
|
||
</th>
|
||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Order ID</th>
|
||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Customer</th>
|
||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Date</th>
|
||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Status</th>
|
||
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Grand Total</th>
|
||
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-28"> </th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||
{loading ? (
|
||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">Loading…</td></tr>
|
||
) : orders.length === 0 ? (
|
||
<tr><td colSpan={7} className="text-center py-10 text-gray-400">No sales orders found</td></tr>
|
||
) : orders.map(so => (
|
||
<tr key={so.name} onClick={() => handleView(so.name)} className={`cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${selectedRows.has(so.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}>
|
||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleRow(so.name)}
|
||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
aria-label={`Select ${so.name}`}
|
||
>
|
||
{selectedRows.has(so.name)
|
||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||
: <FaSquare size={18} />}
|
||
</button>
|
||
</td>
|
||
<td className="py-3 px-4 font-medium text-gray-900 dark:text-white">{so.name}</td>
|
||
<td className="py-3 px-4 text-gray-700 dark:text-gray-300">{so.customer_name || so.customer || '-'}</td>
|
||
<td className="py-3 px-4 text-gray-500">{so.transaction_date || '-'}</td>
|
||
<td className="py-3 px-4"><span className={`px-2 py-0.5 rounded text-xs font-semibold ${getStatusStyle(so)}`}>{getStatusLabel(so)}</span></td>
|
||
<td className="py-3 px-4 text-right font-semibold text-gray-900 dark:text-white">{so.currency || 'SAR'} {(so.grand_total ?? 0).toFixed(2)}</td>
|
||
<td className="py-2 px-4" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center gap-1">
|
||
<button onClick={() => handleView(so.name)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title="View"><FaEye /></button>
|
||
<button onClick={() => handleEdit(so.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit"><FaEdit /></button>
|
||
<button onClick={() => handleDuplicate(so.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate"><FaCopy /></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{total > PAGE_SIZE && (
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
|
||
<span className="text-xs text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}</span>
|
||
<div className="flex gap-2">
|
||
<button disabled={page === 0} onClick={() => goPage(page - 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Prev</button>
|
||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => goPage(page + 1)} className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-40">Next</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SalesOrderList;
|