221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { FaPlus, FaSearch, FaSpinner, FaUserFriends, FaArrowLeft, FaFileExport } from 'react-icons/fa';
|
|
import masterService, { Customer } from '../services/masterService';
|
|
import { toast, ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import DynamicExportModal from '../components/DynamicExportModal';
|
|
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
|
import { useListPageSelection } from '../hooks/useListPageSelection';
|
|
|
|
const statusBadge = (disabled?: number) =>
|
|
disabled
|
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300';
|
|
|
|
const CustomerList: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
const PAGE = 20;
|
|
|
|
const apiFilters = useMemo(() => {
|
|
const f: Record<string, any> = {};
|
|
if (search.trim()) f.customer_name = ['like', `%${search.trim()}%`];
|
|
return f;
|
|
}, [search]);
|
|
|
|
const load = useCallback(async (p = 0) => {
|
|
setLoading(true);
|
|
try {
|
|
const [{ data }, cnt] = await Promise.all([
|
|
masterService.getCustomers({ limit_start: p * PAGE, limit_page_length: PAGE, filters: apiFilters }),
|
|
masterService.getCustomerCount(apiFilters),
|
|
]);
|
|
setCustomers(data);
|
|
setTotal(cnt);
|
|
setPage(p);
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : 'Failed to load customers');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [apiFilters]);
|
|
|
|
useEffect(() => { load(0); }, [load]);
|
|
|
|
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
|
|
const {
|
|
selectedRows,
|
|
toggleRow,
|
|
toggleAllOnPage,
|
|
allOnPageSelected,
|
|
someOnPageSelected,
|
|
} = useListPageSelection(customers, selectionResetKey);
|
|
|
|
const fetchAllForExport = useCallback(
|
|
() => fetchAllRowsForExport({ doctype: 'Customer', filters: apiFilters, orderBy: 'modified desc' }),
|
|
[apiFilters],
|
|
);
|
|
|
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearch(e.target.value);
|
|
};
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<ToastContainer position="top-right" autoClose={3000} />
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<button onClick={() => navigate('/projects')} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
|
|
<FaArrowLeft />
|
|
</button>
|
|
<div className="w-10 h-10 rounded-xl bg-cyan-600 flex items-center justify-center">
|
|
<FaUserFriends className="text-white text-lg" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Customers</h1>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">{total} total</p>
|
|
</div>
|
|
<div className="ml-auto flex flex-wrap gap-2 items-center">
|
|
<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) || loading}
|
|
>
|
|
<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>
|
|
<div className="relative">
|
|
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
|
<input
|
|
value={search} onChange={handleSearch}
|
|
placeholder="Search customers…"
|
|
className="pl-8 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-52 focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/customers/new')}
|
|
className="flex items-center gap-2 px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 text-sm font-medium"
|
|
>
|
|
<FaPlus size={11} /> New Customer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<DynamicExportModal
|
|
isOpen={showExportModal}
|
|
onClose={() => setShowExportModal(false)}
|
|
doctype="Customer"
|
|
selectedCount={selectedRows.size}
|
|
pageCount={customers.length}
|
|
totalCount={total}
|
|
pageData={customers}
|
|
selectedRows={selectedRows}
|
|
rowKey="name"
|
|
onFetchAll={fetchAllForExport}
|
|
fileNamePrefix="customers"
|
|
/>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex justify-center items-center py-16">
|
|
<FaSpinner className="animate-spin text-cyan-500 text-2xl" />
|
|
</div>
|
|
) : customers.length === 0 ? (
|
|
<div className="py-16 text-center text-gray-400 dark:text-gray-500">
|
|
<FaUserFriends className="mx-auto text-4xl mb-3 opacity-30" />
|
|
<p className="text-sm">No customers found</p>
|
|
<button onClick={() => navigate('/customers/new')} className="mt-3 text-sm text-cyan-600 hover:underline">+ Create first customer</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Header row */}
|
|
<div className="grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
<div className="px-2 py-3 flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
|
checked={allOnPageSelected}
|
|
ref={el => {
|
|
if (el) el.indeterminate = someOnPageSelected;
|
|
}}
|
|
onChange={toggleAllOnPage}
|
|
aria-label="Select all on page"
|
|
/>
|
|
</div>
|
|
<div className="px-4 py-3">Customer Name</div>
|
|
<div className="px-4 py-3">Type</div>
|
|
<div className="px-4 py-3">Customer Group</div>
|
|
<div className="px-4 py-3">Territory</div>
|
|
<div className="px-4 py-3">Status</div>
|
|
</div>
|
|
|
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{customers.map(c => (
|
|
<div
|
|
key={c.name}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => navigate(`/customers/${encodeURIComponent(c.name)}`)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
navigate(`/customers/${encodeURIComponent(c.name)}`);
|
|
}
|
|
}}
|
|
className={`grid grid-cols-[2.5rem_1fr_1fr_1fr_1fr_100px] w-full text-left hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors cursor-pointer ${selectedRows.has(c.name) ? 'bg-cyan-50/70 dark:bg-cyan-900/15' : ''}`}
|
|
>
|
|
<div className="px-2 py-3 flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
className="rounded border-gray-300 dark:border-gray-600 text-cyan-600 focus:ring-cyan-500"
|
|
checked={selectedRows.has(c.name)}
|
|
onChange={() => toggleRow(c.name)}
|
|
aria-label={`Select ${c.name}`}
|
|
/>
|
|
</div>
|
|
<div className="px-4 py-3">
|
|
<p className="text-sm font-medium text-indigo-600 dark:text-indigo-400">{c.customer_name || c.name}</p>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500">{c.name}</p>
|
|
</div>
|
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_type || '-'}</div>
|
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.customer_group || '-'}</div>
|
|
<div className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{c.territory || '-'}</div>
|
|
<div className="px-4 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge(c.disabled)}`}>
|
|
{c.disabled ? 'Disabled' : 'Active'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
<span>Page {page + 1}</span>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => load(page - 1)} disabled={page === 0} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Prev</button>
|
|
<button onClick={() => load(page + 1)} disabled={customers.length < PAGE} className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded disabled:opacity-40 hover:bg-gray-50 dark:hover:bg-gray-700">Next</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CustomerList;
|