Seera-Unified-UI/asm_app/src/pages/SalesOrderList.tsx

340 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;