2026-06-11 19:56:20 +05:30

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;