Finalize CRM module: bookings, pricing, survey with full UI integration and validation

This commit is contained in:
2026-06-22 17:47:36 +05:30
parent 59fc91f034
commit 72a1eb0701
23 changed files with 2620 additions and 326 deletions

View File

@@ -33,62 +33,11 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount';
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
import { fetchClients, deleteClient } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
import ClientFormDialog from './ClientFormDialog';
const generateLogicalId = (id) => {
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
};
// Map a raw Qdrant point from doormile_clients to a flat client row.
function toClient(point) {
const p = point.payload || {};
// Qdrant point.id is usually a UUID.
// We generate a clean CLI-XXXXXX format based on it.
const logicalId = generateLogicalId(point.id);
return {
id: point.id,
logicalId,
// Force override the ugly payload client_timestamp string with the clean ID
clientId: logicalId,
name: p.name || '—',
phone: p.phone || '',
city: p.city || '',
businessState: p.businessState || '',
businessType: p.businessType || '',
status: p.status || 'unknown',
parcelVolume: p.parcelVolume ?? 0,
activeContracts: p.activeContracts ?? 0,
frequency: p.frequency || '',
provider: p.provider || '',
efficiency: p.efficiency || '',
logisticsSegment: p.logisticsSegment || '',
transitFrom: p.transitFrom || '',
transitTo: p.transitTo || '',
neighbourhood: p.neighbourhood || p.surveyZone || '',
surveyAddress: p.surveyAddress || '',
surveyLat: p.surveyLat ?? p.surveyGeo?.lat ?? '',
surveyLng: p.surveyLng ?? p.surveyGeo?.lon ?? '',
dataConsent: p.dataConsent || '',
lastUpdated: p.lastUpdated || '',
notes: p.notes || ''
};
}
// Humanize raw payload tokens like `basicOnly`, `newClient`, `first_mile` → "Basic Only".
const humanize = (s) =>
String(s || '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
const titleCase = humanize;
// Map categorical enum values to a semantic palette color.
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
const efficiencyTone = (v) => {
@@ -421,16 +370,28 @@ export default function Tenants() {
const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const load = () => {
setLoading(true);
setError(null);
fetchPoints(COLLECTIONS.clients)
const load = (silent = false) => {
if (!silent) {
setLoading(true);
setError(null);
}
fetchClients()
.then((points) => setClients(points.map(toClient)))
.catch((e) => setError(e.message || 'Failed to load clients'))
.finally(() => setLoading(false));
.catch((e) => {
if (!silent) setError(e.message || 'Failed to load clients');
})
.finally(() => {
if (!silent) setLoading(false);
});
};
useEffect(() => { load(); }, []);
useEffect(() => {
load();
const intervalId = setInterval(() => {
load(true); // Silent poll every 10 seconds
}, 10000);
return () => clearInterval(intervalId);
}, []);
const stats = useMemo(() => ({
total: clients.length,
@@ -473,7 +434,7 @@ export default function Tenants() {
const confirmDelete = async () => {
setDeleting(true);
try {
await deletePoint(COLLECTIONS.clients, toDelete.id);
await deleteClient(toDelete.id);
setToDelete(null);
load();
} catch (e) {