detail view and forms made changes
This commit is contained in:
parent
77c596e3a1
commit
265cde7f8c
49
package-lock.json
generated
49
package-lock.json
generated
@ -8,11 +8,14 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
@ -589,6 +592,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
@ -1251,6 +1265,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@ -1258,6 +1278,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
@ -2605,6 +2634,12 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -3184,6 +3219,20 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
|
||||
@ -10,11 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,239 +4,382 @@ import { DAILY_REPORTS, APP_ID } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { Calendar, Clock, Map, User, ClipboardList, Camera, MapPin, Hash, CheckCircle, XCircle, Activity, ArrowLeft, Mail } from 'lucide-react';
|
||||
import { Button } from '../buttons/Button';
|
||||
import { Map, User, ClipboardList, Camera, Clock } from 'lucide-react';
|
||||
|
||||
export interface DailyLogDetailProps {
|
||||
instanceId: number | string;
|
||||
onPunchOut?: () => void;
|
||||
instanceId: number | string;
|
||||
refreshKey?: number;
|
||||
onPunchOut?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps) {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function DailyLogDetail({ instanceId, refreshKey, onPunchOut, onBack }: DailyLogDetailProps) {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId);
|
||||
if (live) setData(r.data || {});
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (live) setLoading(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId);
|
||||
if (live) setData(r.data || {});
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (live) setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => { live = false; };
|
||||
}, [instanceId]);
|
||||
run();
|
||||
return () => { live = false; };
|
||||
}, [instanceId, refreshKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title="Daily Log Details">
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Card title={`Daily Log #${instanceId}`}>
|
||||
<EmptyState title={`Couldn't load record`} hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title="Daily Log Details">
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title={`Daily Log #${instanceId}`}>
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details..." /></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return (
|
||||
<Card title="Daily Log Details">
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return (
|
||||
<Card title={`Daily Log #${instanceId}`}>
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const remainingData = { ...data };
|
||||
const extract = (key: string, obj: any = data) => {
|
||||
if (obj && key in obj) {
|
||||
const val = obj[key];
|
||||
delete obj[key];
|
||||
return val;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
const remainingData = { ...data };
|
||||
const extract = (key: string, obj: any = data) => {
|
||||
if (obj && key in obj) {
|
||||
const val = obj[key];
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// State
|
||||
const stateName = extract('current_state_name', remainingData) as string;
|
||||
delete remainingData.current_state_id;
|
||||
// State
|
||||
const stateName = extract('current_state_name', remainingData) as string || '-';
|
||||
const isPunchedIn = stateName.toLowerCase().includes('punched in');
|
||||
|
||||
const isPunchedIn = stateName.toLowerCase().includes('punched in');
|
||||
const isPunchedOut = stateName.toLowerCase().includes('punched out');
|
||||
const colorClass = isPunchedOut ? 'text-emerald-500' : (isPunchedIn ? 'text-blue-500' : 'text-slate-500');
|
||||
// Date and Time
|
||||
let dateRaw = extract('date', remainingData);
|
||||
let timeRaw = extract('time', remainingData);
|
||||
let formattedDate = '-';
|
||||
if (dateRaw) {
|
||||
formattedDate = new Date(dateRaw as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
// General Info
|
||||
const date = extract('date', remainingData);
|
||||
const time = extract('time', remainingData);
|
||||
const routeCode = extract('route_code', remainingData);
|
||||
const subRoute = extract('sub_route', remainingData);
|
||||
const dayPlanNotes = extract('day_plan_notes', remainingData);
|
||||
// Format punch in text
|
||||
const punchInTimeText = timeRaw ? `Punch-in ${timeRaw}` : 'Punch-in time unknown';
|
||||
|
||||
// Officer Info
|
||||
const officerNameRaw = extract('sales_officer_name', remainingData);
|
||||
let officerName = '-';
|
||||
let officerEmail = '-';
|
||||
if (officerNameRaw && typeof officerNameRaw === 'object') {
|
||||
officerName = (officerNameRaw as any).name || '-';
|
||||
officerEmail = (officerNameRaw as any).email || '-';
|
||||
}
|
||||
// Route Info
|
||||
const routeCode = extract('route_code', remainingData) || '-';
|
||||
const subRoute = extract('sub_route', remainingData) || '-';
|
||||
|
||||
// Image
|
||||
const storeImage = extract('store_image', remainingData);
|
||||
// Notes & Details
|
||||
const dayPlanNotes = extract('day_plan_notes', remainingData) || '-';
|
||||
const eodNotes = extract('eod_notes_remarks', remainingData) || '-';
|
||||
|
||||
// EOD Info
|
||||
const totalProductive = extract('total_productive_calls', remainingData);
|
||||
const totalNonProductive = extract('total_non_productive_calls', remainingData);
|
||||
const eodNotes = extract('eod_notes_remarks', remainingData);
|
||||
// KPIs
|
||||
const prodCalls = extract('total_productive_calls', remainingData) || 0;
|
||||
const nonProdCalls = extract('total_non_productive_calls', remainingData) || 0;
|
||||
const totalCalls = Number(prodCalls) + Number(nonProdCalls);
|
||||
const productivity = totalCalls > 0 ? Math.round((Number(prodCalls) / totalCalls) * 100) : 0;
|
||||
|
||||
// Meta
|
||||
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||
// SO Info
|
||||
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'sales_officer_name';
|
||||
const soRaw = extract(soKey, remainingData);
|
||||
let soName = '-';
|
||||
let soEmail = '-';
|
||||
let soId = '-';
|
||||
if (soRaw && typeof soRaw === 'object') {
|
||||
soName = (soRaw as any).name || '-';
|
||||
soEmail = (soRaw as any).email || '-';
|
||||
soId = (soRaw as any).user_id || (soRaw as any).id || (soRaw as any).uid || '-';
|
||||
}
|
||||
const soInitials = soName.length > 2 ? soName.substring(0, 2).toUpperCase() : 'SO';
|
||||
|
||||
// Helper Row
|
||||
const Row = ({ label, value }: { label: string, value: any }) => {
|
||||
let finalValue = value;
|
||||
if (finalValue && typeof finalValue === 'object') {
|
||||
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Meta
|
||||
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||
const createdAtRaw = createdAtKey ? extract(createdAtKey, remainingData) : null;
|
||||
let createdAt = '-';
|
||||
if (createdAtRaw) {
|
||||
createdAt = new Date(createdAtRaw as string).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
// Image
|
||||
const storeImage = extract('store_image', remainingData);
|
||||
const imageFiles = Array.isArray(storeImage) ? storeImage : [];
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className={`border-t-[4px] shadow-md ${isPunchedOut ? 'border-emerald-500' : 'border-[var(--z-bg-primary)]'}`}>
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Current State</div>
|
||||
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||
{stateName}
|
||||
</div>
|
||||
</div>
|
||||
{isPunchedIn && onPunchOut && (
|
||||
<Button onClick={onPunchOut}>Punch Out</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
return (
|
||||
<div className="w-full max-w-[1000px] mx-auto flex flex-col gap-6 pb-12">
|
||||
|
||||
{/* 2. Route & Plan Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Map className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Day Plan & Route</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Date" value={date} />
|
||||
<Row label="Time" value={time} />
|
||||
<Row label="Route Code" value={routeCode} />
|
||||
<Row label="Sub Route" value={subRoute} />
|
||||
<Row label="Day Plan Notes" value={dayPlanNotes} />
|
||||
</div>
|
||||
</Card>
|
||||
{/* Top Header Card */}
|
||||
<div className="border border-slate-200 rounded-xl p-6 shadow-sm bg-white">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-10 h-10 flex mt-1 items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 transition-colors text-slate-600 shrink-0 border border-slate-200 shadow-sm"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Day Plan</div>
|
||||
<div className="text-3xl font-black text-slate-900 mb-2">{formattedDate}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5"><Clock size={14} /> {punchInTimeText}</span>
|
||||
<span className="flex items-center gap-1.5"><MapPin size={14} /> Route {String(routeCode)} - {String(subRoute)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Officer Info */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Officer Name" value={officerName} />
|
||||
<Row label="Officer Email" value={officerEmail} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 4. EOD Details (Only if not punched in) */}
|
||||
{!isPunchedIn && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">End of Day Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Total Productive Calls" value={totalProductive} />
|
||||
<Row label="Total Non-Productive Calls" value={totalNonProductive} />
|
||||
<Row label="EOD Notes (Remarks)" value={eodNotes} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 5. Image Card */}
|
||||
{Array.isArray(storeImage) && storeImage.length > 0 && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Camera className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{storeImage.map((file: any, idx: number) => {
|
||||
const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||
return (
|
||||
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.original_name || 'Upload'}
|
||||
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||
}}
|
||||
/>
|
||||
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
|
||||
<div className="flex flex-col items-end gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-900 text-white flex items-center justify-center font-bold text-sm shrink-0">
|
||||
{soInitials}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-slate-900">{String(soName)}</div>
|
||||
<div className="text-[10px] text-slate-500">Sales Officer - ID {String(soId)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPunchedIn && onPunchOut && (
|
||||
<Button onClick={onPunchOut}>
|
||||
Punch Out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 6. Other Details */}
|
||||
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0) && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Clock className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Performed At" value={createdAt} />
|
||||
{Object.entries(remainingData).map(([key, value]) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||
{/* KPI Row & Progress Bar (Only show if Punched Out) */}
|
||||
{!isPunchedIn && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-emerald-500 uppercase tracking-wider mb-3">
|
||||
<CheckCircle size={14} /> Productive Calls
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{String(prodCalls)}</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-rose-500 uppercase tracking-wider mb-3">
|
||||
<XCircle size={14} /> Non Productive
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{String(nonProdCalls)}</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-3">
|
||||
<Activity size={14} /> Total Calls
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{totalCalls}</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-blue-500 uppercase tracking-wider mb-3">
|
||||
<Activity size={14} /> Productivity
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{productivity}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const label = key.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||
.replace(/^\s+|\s+$/g, '');
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-500 mb-2">
|
||||
<span>Productive vs Non Productive</span>
|
||||
<span>{String(prodCalls)} / {totalCalls}</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex">
|
||||
{totalCalls > 0 && (
|
||||
<>
|
||||
<div className="h-full bg-emerald-500 transition-all duration-500" style={{ width: `${productivity}%` }}></div>
|
||||
<div className="h-full bg-rose-400 transition-all duration-500" style={{ width: `${100 - productivity}%` }}></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
if (!label) return null;
|
||||
{/* Split Content */}
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
|
||||
return <Row key={key} label={label} value={value} />;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{/* Left Column */}
|
||||
<div className="flex-[2] flex flex-col gap-6">
|
||||
{/* Timeline */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-6">
|
||||
<Calendar size={14} className="text-slate-500" /> Day Timeline
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
<div className="relative pl-6 flex flex-col gap-8 border-l border-slate-200 ml-3">
|
||||
{/* Punched In */}
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[31px] bg-white rounded-full p-0.5">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Clock size={10} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-900 -mt-0.5">Punched In</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">{formattedDate} {timeRaw ? `- ${timeRaw}` : ''}</div>
|
||||
</div>
|
||||
|
||||
{/* Day Plan */}
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[31px] bg-white rounded-full p-0.5">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-slate-300 bg-white flex items-center justify-center"></div>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-900 -mt-0.5">Day Plan Recorded</div>
|
||||
<div className="text-[10px] text-slate-600 mt-1">{String(dayPlanNotes)}</div>
|
||||
</div>
|
||||
|
||||
{/* EOD Submitted */}
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[31px] bg-white rounded-full p-0.5">
|
||||
<div className={`w-4 h-4 rounded-full flex items-center justify-center ${isPunchedIn ? 'bg-slate-300' : 'bg-slate-900'}`}>
|
||||
<ClipboardList size={10} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-sm font-bold -mt-0.5 ${isPunchedIn ? 'text-slate-400' : 'text-slate-900'}`}>EOD Submitted</div>
|
||||
{!isPunchedIn && (
|
||||
<div className="text-[10px] text-slate-500 mt-1">{createdAt}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes Row */}
|
||||
<div className={`grid grid-cols-1 ${!isPunchedIn ? 'md:grid-cols-2' : ''} gap-6`}>
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<ClipboardList size={14} className="text-slate-500" /> Day Plan Notes
|
||||
</div>
|
||||
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-wrap">{String(dayPlanNotes)}</div>
|
||||
</div>
|
||||
{!isPunchedIn && (
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6 flex flex-col">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<ClipboardList size={14} className="text-slate-500" /> EOD Remarks
|
||||
</div>
|
||||
<div className="text-xs text-slate-700 leading-relaxed whitespace-pre-wrap">{String(eodNotes)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Assignment */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-6">
|
||||
<Map size={14} className="text-slate-500" /> Route Assignment
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border border-slate-100 rounded-xl p-4 bg-slate-50 flex flex-col justify-between">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">Route Code</div>
|
||||
<div className="text-lg font-bold text-slate-900">{String(routeCode)}</div>
|
||||
</div>
|
||||
<div className="border border-slate-100 rounded-xl p-4 bg-slate-50 flex flex-col justify-between">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">Sub Route</div>
|
||||
<div className="text-lg font-bold text-slate-900">{String(subRoute)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="flex-1 flex flex-col gap-6 min-w-[280px]">
|
||||
{/* Store Image */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<Camera size={14} className="text-slate-500" /> Store Image
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{imageFiles.length > 0 ? imageFiles.map((file: any, idx: number) => {
|
||||
const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||
return (
|
||||
<div key={file.uuid || idx} className="rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
||||
<img src={previewUrl} alt="Store" className="w-full h-auto object-cover" />
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="w-full h-32 rounded-lg bg-slate-50 border border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
|
||||
<Camera size={24} className="mb-2 opacity-50" />
|
||||
<span className="text-xs">No image uploaded</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-400 mt-2">Uploaded at punch in.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SO Card */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-5">
|
||||
<User size={14} className="text-slate-500" /> Sales Officer
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
||||
<User size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Name</div>
|
||||
<div className="text-xs font-semibold text-slate-900">{String(soName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
||||
<Mail size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Email</div>
|
||||
<div className="text-xs font-semibold text-slate-900 truncate">{String(soEmail)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
||||
<Hash size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">User ID</div>
|
||||
<div className="text-xs font-semibold text-slate-900">{String(soId)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Card */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-6">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<Hash size={14} className="text-slate-500" /> Meta
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">Instance</span>
|
||||
<span className="font-bold text-slate-900">#{instanceId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">State</span>
|
||||
<span className="font-bold text-slate-900">{stateName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">Performed At</span>
|
||||
<span className="font-bold text-slate-900">{createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,9 +4,7 @@ import { ORDER_BOOKING } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { ShoppingCart, Store, User, Package, Clock, ClipboardList } from 'lucide-react';
|
||||
import { GridTable } from './GridTable';
|
||||
import { Store, User, Package, Calendar, Hash, Mail, FileText, Weight, List, MapPin } from 'lucide-react';
|
||||
|
||||
export interface WiredDetailViewProps {
|
||||
instanceId: number | string;
|
||||
@ -38,23 +36,23 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title="Order Details">
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
<Card title={`Order #${instanceId}`}>
|
||||
<EmptyState title={`Couldn't load record`} hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title="Order Details">
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||
<Card title={`Order #${instanceId}`}>
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details..." /></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return (
|
||||
<Card title="Order Details">
|
||||
<Card title={`Order #${instanceId}`}>
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
@ -67,28 +65,34 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
|
||||
delete obj[key];
|
||||
return val;
|
||||
}
|
||||
return '-';
|
||||
return null;
|
||||
};
|
||||
|
||||
// State
|
||||
const stateName = extract('current_state_name', remainingData);
|
||||
const stateName = extract('current_state_name', remainingData) || 'Ordered';
|
||||
delete remainingData.current_state_id;
|
||||
const isSuccess = ['ordered', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
|
||||
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
|
||||
|
||||
// Order Details
|
||||
const orderId = extract('order_id', remainingData);
|
||||
const dateOfOrder = extract('date_of_order_3', remainingData);
|
||||
const orderId = extract('order_id', remainingData) || '-';
|
||||
let dateOfOrder = extract('date_of_order_3', remainingData);
|
||||
if (dateOfOrder) {
|
||||
dateOfOrder = new Date(dateOfOrder as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
} else {
|
||||
dateOfOrder = '-';
|
||||
}
|
||||
|
||||
// SO Info
|
||||
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name';
|
||||
const soRaw = extract(soKey, remainingData);
|
||||
let soName = '-';
|
||||
let soEmail = '-';
|
||||
let soId = '-';
|
||||
if (soRaw && typeof soRaw === 'object') {
|
||||
soName = (soRaw as any).name || '-';
|
||||
soEmail = (soRaw as any).email || '-';
|
||||
soId = (soRaw as any).user_id || (soRaw as any).id || (soRaw as any).uid || '-';
|
||||
}
|
||||
const soInitials = soName.length > 2 ? soName.substring(0,2).toUpperCase() : 'SO';
|
||||
|
||||
// Store Info
|
||||
const storeRaw = extract('select_store', remainingData);
|
||||
@ -105,144 +109,171 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
|
||||
|
||||
// Line Items
|
||||
const orderDetailsGrid = extract('order_details_3', remainingData);
|
||||
const itemsArray = Array.isArray(orderDetailsGrid) ? orderDetailsGrid : [];
|
||||
|
||||
// Totals
|
||||
const totalBags = extract('total_bags_3', remainingData);
|
||||
const totalKgs = extract('total_kgs_3', remainingData);
|
||||
|
||||
// Meta
|
||||
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||
|
||||
// Helper Row
|
||||
const Row = ({ label, value, highlight = false }: { label: string, value: any, highlight?: boolean }) => {
|
||||
let finalValue = value;
|
||||
if (finalValue && typeof finalValue === 'object') {
|
||||
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||
<span className={`text-sm ${highlight ? 'font-black text-[var(--z-bg-primary)] text-base' : 'font-bold text-slate-800'} sm:w-2/3`}>
|
||||
{formatValue(finalValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const totalBags = extract('total_bags_3', remainingData) || 0;
|
||||
const totalKgs = extract('total_kgs_3', remainingData) || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 bg-white p-1">
|
||||
{/* Top Section */}
|
||||
<div className="border border-slate-200 rounded-xl p-6 bg-white shadow-sm flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Order ID</div>
|
||||
<div className="text-2xl font-black text-slate-900 mb-2">{String(orderId)}</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1.5"><Calendar size={14} /> {String(dateOfOrder)}</span>
|
||||
<span className="flex items-center gap-1.5"><Hash size={14} /> Instance #{instanceId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-100 bg-slate-50 rounded-xl p-4 flex flex-col gap-1 min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
<Store size={12} /> Store
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-900">{String(storeName)}</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-slate-500 mt-1">
|
||||
<span className="flex items-center gap-1"><MapPin size={12} /> {String(routeCode)} - {String(routeName)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-slate-500 mt-0.5">
|
||||
<span className="flex items-center gap-1"><User size={12} /> {String(distributorName)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
|
||||
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||
{stateName}
|
||||
{/* Middle Stats Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="border border-slate-200 rounded-xl p-5 bg-white shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">
|
||||
<Package size={14} className="text-emerald-500" /> Total Bags
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{String(totalBags)}</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-xl p-5 bg-white shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">
|
||||
<Weight size={14} className="text-blue-500" /> Total Kilograms
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{Number(totalKgs).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-xl p-5 bg-white shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">
|
||||
<List size={14} className="text-slate-500" /> Line Items
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{itemsArray.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left Table */}
|
||||
<div className="flex-[2] border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden flex flex-col min-w-0">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center gap-2 bg-slate-50">
|
||||
<Package size={14} className="text-slate-500" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Order Items</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50/50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-3 border-b border-slate-100">Product</th>
|
||||
<th className="px-5 py-3 border-b border-slate-100">Category</th>
|
||||
<th className="px-5 py-3 border-b border-slate-100">SKU</th>
|
||||
<th className="px-5 py-3 border-b border-slate-100 text-right">Bags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{itemsArray.map((item: any, idx: number) => {
|
||||
const product = item.product_name_3 || '-';
|
||||
const category = item.product_category_3 || '-';
|
||||
const sku = item.sku_code_3 || '-';
|
||||
const bags = item.bags_3 || '0';
|
||||
const brCode = item.br_code_3 || '-';
|
||||
const weight = item.sku_3 ? `${item.sku_3} kg` : '-';
|
||||
|
||||
{/* 2. Order Summary Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Summary</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Order ID" value={orderId} />
|
||||
<Row label="Date of Order" value={dateOfOrder} />
|
||||
</div>
|
||||
</Card>
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-bold text-slate-900 text-xs mb-0.5 whitespace-nowrap">{String(product)}</div>
|
||||
<div className="text-[10px] text-slate-500">BR Code {String(brCode)}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-slate-600 whitespace-nowrap">{String(category)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-xs text-slate-600 mb-0.5 whitespace-nowrap">{String(sku)}</div>
|
||||
<div className="text-[10px] text-slate-500 whitespace-nowrap">{String(weight)}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-slate-900 text-xs whitespace-nowrap">{String(bags)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-slate-50 font-bold">
|
||||
<td colSpan={3} className="px-5 py-3 text-slate-900 text-xs">Total</td>
|
||||
<td className="px-5 py-3 text-right text-slate-900 text-xs">{String(totalBags)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Store details Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Store className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Business Name" value={storeName} />
|
||||
<Row label="Distributor" value={distributorName} />
|
||||
<Row label="Route" value={`${routeName} (${routeCode})`} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 4. Sales Officer Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Name" value={soName} />
|
||||
<Row label="Email" value={soEmail} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 5. Line Items Grid */}
|
||||
{Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && (
|
||||
<Card pad={false} className="shadow-md overflow-hidden">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Package className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Line Items</h3>
|
||||
</div>
|
||||
<div className="p-0 sm:p-5">
|
||||
<GridTable
|
||||
data={orderDetailsGrid}
|
||||
columns={[
|
||||
{ id: 'product_category_3', uid: '', name: 'Product Category', data_type: 'text' },
|
||||
{ id: 'product_name_3', uid: '', name: 'Product Name', data_type: 'text' },
|
||||
{ id: 'sku_3', uid: '', name: 'SKU', data_type: 'text' },
|
||||
{ id: 'sku_code_3', uid: '', name: 'SKU Code', data_type: 'text' },
|
||||
{ id: 'br_code_3', uid: '', name: 'BR Code', data_type: 'text' },
|
||||
{ id: 'bags_3', uid: '', name: 'Bags', data_type: 'number' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 6. Order Totals */}
|
||||
<Card pad={false} className="shadow-md bg-blue-50/30">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-blue-100/30">
|
||||
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Totals</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Total Bags" value={totalBags} highlight />
|
||||
<Row label="Total KGs" value={totalKgs} highlight />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 7. Log Visit Details Fallback & Meta */}
|
||||
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0 || createdAt !== '-') && (
|
||||
<Card pad={false} className="shadow-md mt-6">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Clock className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Created At" value={createdAt} />
|
||||
{Object.entries(remainingData).map(([key, value]) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||
|
||||
const label = key.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||
.replace(/^\s+|\s+$/g, '');
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
return <Row key={key} label={label} value={value} />;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{/* Right Sidebar */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-[280px]">
|
||||
{/* SO Card */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<User size={14} className="text-slate-500" /> Sales Officer
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-900 text-white flex items-center justify-center font-bold text-sm shrink-0">
|
||||
{soInitials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-slate-900">{String(soName)}</div>
|
||||
<div className="text-[10px] text-slate-500">Sales Officer - ID {String(soId)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
||||
<Mail size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Email</div>
|
||||
<div className="text-xs font-semibold text-slate-900 truncate">{String(soEmail)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center shrink-0">
|
||||
<Hash size={12} className="text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">User ID</div>
|
||||
<div className="text-xs font-semibold text-slate-900">{String(soId)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Meta */}
|
||||
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden p-5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-4">
|
||||
<Hash size={14} className="text-slate-500" /> Order Meta
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">Instance</span>
|
||||
<span className="font-bold text-slate-900">#{instanceId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">State</span>
|
||||
<span className="font-bold text-slate-900">{String(stateName)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-500">Date of Order</span>
|
||||
<span className="font-bold text-slate-900">{String(dateOfOrder)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,316 +4,463 @@ import { STORE, APP_ID } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { Store, User, Truck, MapPin, TrendingUp, Camera, ClipboardList } from 'lucide-react';
|
||||
import { GridTable } from './GridTable';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
import { ArrowLeft, Store, User, Truck, MapPin, TrendingUp, Phone, Navigation2, Edit3, Navigation, Mail, FileText, Hash } from 'lucide-react';
|
||||
import { Button } from '../buttons/Button';
|
||||
|
||||
export function StoreDetail({ instanceId }: WiredDetailViewProps) {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId);
|
||||
if (live) setData(r.data || {});
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (live) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => { live = false; };
|
||||
}, [instanceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const remainingData = { ...data };
|
||||
const extract = (key: string, obj: any = data) => {
|
||||
if (obj && key in obj) {
|
||||
const val = obj[key];
|
||||
delete obj[key];
|
||||
return val;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
// State
|
||||
const stateName = extract('current_state_name', remainingData);
|
||||
delete remainingData.current_state_id;
|
||||
const isSuccess = ['created', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s));
|
||||
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500';
|
||||
|
||||
// Store Overview
|
||||
const storeCode = extract('store_code', remainingData);
|
||||
const businessName = extract('business_name', remainingData);
|
||||
const area = extract('area', remainingData);
|
||||
const completeAddress = extract('complete_address', remainingData);
|
||||
const pinCode = extract('pin_code', remainingData);
|
||||
const notes = extract('notes', remainingData);
|
||||
|
||||
// Owner Info
|
||||
const ownerName = extract('owner_name', remainingData);
|
||||
const email = extract('email', remainingData);
|
||||
let phoneNumber = extract('phone_number', remainingData);
|
||||
if (typeof phoneNumber === 'object' && phoneNumber !== null) {
|
||||
phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-';
|
||||
}
|
||||
|
||||
// Distributor Info
|
||||
const distributorName = extract('distributor_name', remainingData);
|
||||
const distributorOwnerName = extract('distributor_owner_name', remainingData);
|
||||
const distributorEmail = extract('distributor_email', remainingData);
|
||||
let distributorPhone = extract('distributor_phone_number', remainingData);
|
||||
if (typeof distributorPhone === 'object' && distributorPhone !== null) {
|
||||
distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-';
|
||||
}
|
||||
|
||||
// Route Info
|
||||
const routeCode = extract('route_code', remainingData);
|
||||
const routeName = extract('route_name', remainingData);
|
||||
const subRoute = extract('sub_route', remainingData);
|
||||
|
||||
// Location
|
||||
const storeLocation = extract('store_location', remainingData);
|
||||
let lat = null;
|
||||
let lng = null;
|
||||
if (storeLocation) {
|
||||
let locObj = storeLocation;
|
||||
if (typeof locObj === 'string') {
|
||||
try { locObj = JSON.parse(locObj); } catch (e) {}
|
||||
}
|
||||
if (locObj && typeof locObj === 'object') {
|
||||
lat = (locObj as any).latitude;
|
||||
lng = (locObj as any).longitude;
|
||||
}
|
||||
}
|
||||
|
||||
// Potential
|
||||
const potential = extract('potential', remainingData);
|
||||
|
||||
// Image
|
||||
const storeImage = extract('store_image', remainingData);
|
||||
|
||||
// Meta
|
||||
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||
|
||||
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
|
||||
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
|
||||
const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-';
|
||||
|
||||
// Helper Row
|
||||
const Row = ({ label, value }: { label: string, value: any }) => {
|
||||
let finalValue = value;
|
||||
if (finalValue && typeof finalValue === 'object') {
|
||||
try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; }
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0">
|
||||
<span className="text-sm text-muted sm:w-1/3">{label}:</span>
|
||||
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* 1. Status Card */}
|
||||
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md">
|
||||
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div>
|
||||
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}>
|
||||
{stateName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 2. Store Overview Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Store className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Overview</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Store Code" value={storeCode} />
|
||||
<Row label="Business Name" value={businessName} />
|
||||
<Row label="Area" value={area} />
|
||||
<Row label="Complete Address" value={completeAddress} />
|
||||
<Row label="PIN Code" value={pinCode} />
|
||||
<Row label="Notes" value={notes} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 3. Owner Information Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<User className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Owner Information</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Owner Name" value={ownerName} />
|
||||
<Row label="Phone Number" value={phoneNumber} />
|
||||
<Row label="Email" value={email} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 4. Distributor Details Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Truck className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Distributor Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Distributor Name" value={distributorName} />
|
||||
<Row label="Distributor Owner Name" value={distributorOwnerName} />
|
||||
<Row label="Phone Number" value={distributorPhone} />
|
||||
<Row label="Email" value={distributorEmail} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 5. Route Details Card */}
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Route Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Route Name" value={routeName} />
|
||||
<Row label="Route Code" value={routeCode} />
|
||||
<Row label="Sub Route" value={subRoute} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 6. Potential Card */}
|
||||
{Array.isArray(potential) && potential.length > 0 && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<TrendingUp className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Potential</h3>
|
||||
</div>
|
||||
<div className="p-0 sm:p-5">
|
||||
<GridTable
|
||||
data={potential}
|
||||
columns={[
|
||||
{ id: 'product_category', uid: '', name: 'Product Category', data_type: 'text' },
|
||||
{ id: 'quantity', uid: '', name: 'Quantity (Kgs)', data_type: 'number' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 7. Store Image Card */}
|
||||
{Array.isArray(storeImage) && storeImage.length > 0 && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<Camera className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{storeImage.map((file: any, idx: number) => {
|
||||
const previewUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
|
||||
return (
|
||||
<div key={file.uuid || idx} className="relative w-full rounded-lg border border-border-subtle overflow-hidden bg-slate-100 flex items-center justify-center group shadow-sm">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.original_name || 'Store'}
|
||||
className="w-full h-auto max-h-[400px] object-contain transition-transform group-hover:scale-[1.02]"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).parentElement!.innerHTML = `<span class="text-[10px] text-faint text-center px-2 break-all font-mono">${file.original_name || 'File'}</span>`;
|
||||
}}
|
||||
/>
|
||||
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 z-10"></a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 7.5 Store Location Map */}
|
||||
{lat && lng && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<MapPin className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Location</h3>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
<iframe
|
||||
title="Store Location Map"
|
||||
width="100%"
|
||||
height="350"
|
||||
style={{ border: 0 }}
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`}
|
||||
></iframe>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 8. Log Visit Details Fallback & Meta */}
|
||||
{(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k)).length > 0 || userName !== '-') && (
|
||||
<Card pad={false} className="shadow-md">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} />
|
||||
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Other Details</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col">
|
||||
<Row label="Created By" value={userName} />
|
||||
<Row label="Created At" value={createdAt} />
|
||||
{Object.entries(remainingData).map(([key, value]) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey.includes('uuid') || /^\d+$/.test(key) || lowerKey === 'instance_id') return null;
|
||||
|
||||
const label = key.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i, '')
|
||||
.replace(/^\s+|\s+$/g, '');
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
return <Row key={key} label={label} value={value} />;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
export interface StoreDetailProps {
|
||||
instanceId: string | number;
|
||||
onBack?: () => void;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export function StoreDetail({ instanceId, onBack, onEdit }: StoreDetailProps) {
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId);
|
||||
if (live) setData(r.data || {});
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (live) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => { live = false; };
|
||||
}, [instanceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading details…" /></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return (
|
||||
<Card title="Store Details">
|
||||
<EmptyState title="No details found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const remainingData = { ...data };
|
||||
const extract = (key: string, obj: any = data) => {
|
||||
if (obj && key in obj) {
|
||||
const val = obj[key];
|
||||
delete obj[key];
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// State
|
||||
const stateName = extract('current_state_name', remainingData) || '-';
|
||||
delete remainingData.current_state_id;
|
||||
const isSuccess = ['created', 'active', 'approve'].some(s => String(stateName).toLowerCase().includes(s));
|
||||
const badgeClass = isSuccess ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700';
|
||||
|
||||
// Store Overview
|
||||
const storeCode = extract('store_code', remainingData) || '-';
|
||||
const businessName = extract('business_name', remainingData) || '-';
|
||||
const area = extract('area', remainingData);
|
||||
const completeAddress = extract('complete_address', remainingData);
|
||||
const pinCode = extract('pin_code', remainingData);
|
||||
|
||||
// Owner Info
|
||||
const ownerName = extract('owner_name', remainingData) || '-';
|
||||
const email = extract('email', remainingData);
|
||||
let phoneNumber = extract('phone_number', remainingData);
|
||||
let dialPhone = '';
|
||||
if (typeof phoneNumber === 'object' && phoneNumber !== null) {
|
||||
dialPhone = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '';
|
||||
phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-';
|
||||
} else {
|
||||
phoneNumber = phoneNumber || '-';
|
||||
dialPhone = String(phoneNumber);
|
||||
}
|
||||
|
||||
// Distributor Info
|
||||
const distributorName = extract('distributor_name', remainingData);
|
||||
const distributorOwnerName = extract('distributor_owner_name', remainingData);
|
||||
const distributorEmail = extract('distributor_email', remainingData);
|
||||
let distributorPhone = extract('distributor_phone_number', remainingData);
|
||||
if (typeof distributorPhone === 'object' && distributorPhone !== null) {
|
||||
distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-';
|
||||
} else {
|
||||
distributorPhone = distributorPhone || '-';
|
||||
}
|
||||
|
||||
// Route Info
|
||||
const routeCode = extract('route_code', remainingData);
|
||||
const routeName = extract('route_name', remainingData);
|
||||
const subRoute = extract('sub_route', remainingData);
|
||||
|
||||
// Location
|
||||
const storeLocation = extract('store_location', remainingData);
|
||||
let lat = null;
|
||||
let lng = null;
|
||||
if (storeLocation) {
|
||||
let locObj = storeLocation;
|
||||
if (typeof locObj === 'string') {
|
||||
try { locObj = JSON.parse(locObj); } catch (e) { }
|
||||
}
|
||||
if (locObj && typeof locObj === 'object') {
|
||||
lat = (locObj as any).latitude;
|
||||
lng = (locObj as any).longitude;
|
||||
}
|
||||
}
|
||||
|
||||
// Potential
|
||||
const potential = extract('potential', remainingData);
|
||||
let totalPotential = 0;
|
||||
if (Array.isArray(potential)) {
|
||||
potential.forEach((p: any) => {
|
||||
totalPotential += (Number(p.quantity) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Image
|
||||
const storeImage = extract('store_image', remainingData);
|
||||
let imageUrl = '';
|
||||
if (Array.isArray(storeImage) && storeImage.length > 0) {
|
||||
imageUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${storeImage[0].uuid}/preview`;
|
||||
}
|
||||
|
||||
// Meta
|
||||
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
|
||||
const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-';
|
||||
|
||||
const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id'));
|
||||
const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
|
||||
const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full max-w-full">
|
||||
{/* Top Bar */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-900 transition-colors mr-1"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-900 text-white flex items-center justify-center">
|
||||
<Store size={16} />
|
||||
</div>
|
||||
<span className="text-slate-800 font-bold">{String(storeCode)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{dialPhone && dialPhone !== '-' && (
|
||||
<Button size="sm" variant="secondary" iconLeft={<Phone size={14} />} onClick={() => window.open(`tel:${dialPhone}`)}>Call</Button>
|
||||
)}
|
||||
{lat && lng && (
|
||||
<Button size="sm" variant="primary" className="bg-slate-900 hover:bg-slate-800 text-white border-transparent" iconLeft={<Navigation size={14} />} onClick={() => window.open(`https://maps.google.com/maps?daddr=${lat},${lng}`, '_blank')}>Directions</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button size="sm" variant="secondary" iconLeft={<Edit3 size={14} />} onClick={onEdit}>Edit Store</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Card */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col md:flex-row">
|
||||
{/* Image Section */}
|
||||
{imageUrl ? (
|
||||
<div className="md:w-1/3 lg:w-1/4 h-48 md:h-auto relative bg-slate-100">
|
||||
<img src={imageUrl} alt={String(businessName)} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:w-1/3 lg:w-1/4 h-48 md:h-auto flex items-center justify-center bg-slate-100 border-r border-slate-200">
|
||||
<Store size={48} className="text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${badgeClass}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-75"></span>
|
||||
{String(stateName)}
|
||||
</div>
|
||||
<div className="px-2.5 py-1 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Hash size={12} />
|
||||
{String(storeCode)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-black text-slate-900 tracking-tight mb-2">{String(businessName)}</h1>
|
||||
|
||||
<div className="flex items-start gap-2 text-slate-500 text-sm">
|
||||
<MapPin size={16} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{[completeAddress, area].filter(Boolean).join(', ')}
|
||||
{pinCode ? ` — ${pinCode}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Stats Row */}
|
||||
<div className="grid grid-cols-3 border-t border-slate-100 bg-slate-50/50">
|
||||
<div className="p-4 border-r border-slate-100">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Owner</div>
|
||||
<div className="text-sm font-semibold text-slate-900 truncate">{String(ownerName)}</div>
|
||||
</div>
|
||||
<div className="p-4 border-r border-slate-100">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Route</div>
|
||||
<div className="text-sm font-semibold text-slate-900 truncate">{String(routeName || '-')}{subRoute ? ` · ${subRoute}` : ''}</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Potential</div>
|
||||
<div className="text-sm font-semibold text-slate-900 truncate">
|
||||
{totalPotential} <span className="text-slate-500 font-normal">units</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left Column (Spans 2) */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
|
||||
{/* Contact Card */}
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<User size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Contact</h3>
|
||||
</div>
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-y-6 gap-x-8 bg-white">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 flex items-center justify-center shrink-0 border border-slate-100">
|
||||
<User size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Owner</div>
|
||||
<div className="text-sm font-semibold text-slate-900">{String(ownerName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 flex items-center justify-center shrink-0 border border-slate-100">
|
||||
<Phone size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Phone</div>
|
||||
<div className="text-sm font-semibold text-slate-900">{String(phoneNumber)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 flex items-center justify-center shrink-0 border border-slate-100">
|
||||
<Mail size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Email</div>
|
||||
<div className="text-sm font-semibold text-slate-900 break-all">{email ? String(email) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-50 flex items-center justify-center shrink-0 border border-slate-100">
|
||||
<MapPin size={14} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Address</div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{[completeAddress, area, pinCode].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Route Assignment Card */}
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<TrendingUp size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Route Assignment</h3>
|
||||
</div>
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4 bg-white">
|
||||
<div className="p-4 rounded-xl border border-slate-100 bg-slate-50">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Route</div>
|
||||
<div className="text-base font-bold text-slate-900">{routeName ? String(routeName) : '-'}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Code {routeCode ? String(routeCode) : '-'}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-slate-100 bg-slate-50">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Sub Route</div>
|
||||
<div className="text-base font-bold text-slate-900">{subRoute ? String(subRoute) : '-'}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-slate-100 bg-slate-50">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Area</div>
|
||||
<div className="text-base font-bold text-slate-900">{area ? String(area) : '-'}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">PIN {pinCode ? String(pinCode) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Order Potential Card */}
|
||||
{Array.isArray(potential) && potential.length > 0 && (
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<FileText size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Order Potential</h3>
|
||||
</div>
|
||||
<div className="bg-white">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="py-3 px-5 font-bold text-slate-500 text-xs uppercase tracking-wider">Product Category</th>
|
||||
<th className="py-3 px-5 font-bold text-slate-500 text-xs uppercase tracking-wider text-right">Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{potential.map((p: any, idx: number) => {
|
||||
if (!p.product_category && !p.quantity) return null;
|
||||
return (
|
||||
<tr key={idx} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-5 font-semibold text-slate-900">{p.product_category ? String(p.product_category) : '-'}</td>
|
||||
<td className="py-3 px-5 text-slate-700 text-right">{p.quantity ? String(p.quantity) : '0'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-slate-50 border-t border-slate-200 font-bold">
|
||||
<td className="py-3 px-5 text-slate-900">Total</td>
|
||||
<td className="py-3 px-5 text-slate-900 text-right">{totalPotential}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Distributor Card */}
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<Truck size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Distributor</h3>
|
||||
</div>
|
||||
<div className="p-5 bg-white">
|
||||
<div className="p-4 rounded-xl border border-slate-100 bg-slate-50">
|
||||
<div className="text-sm font-bold text-slate-900 mb-1">{distributorName ? String(distributorName) : '-'}</div>
|
||||
<div className="text-xs text-slate-500 mb-4">Owner · {distributorOwnerName ? String(distributorOwnerName) : '-'}</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-white flex items-center justify-center border border-slate-200">
|
||||
<Phone size={12} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Phone</div>
|
||||
<div className="text-xs font-semibold text-slate-900">{String(distributorPhone)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-white flex items-center justify-center border border-slate-200">
|
||||
<Mail size={12} className="text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Email</div>
|
||||
<div className="text-xs font-semibold text-slate-900">{distributorEmail ? String(distributorEmail) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Column (Spans 1) */}
|
||||
<div className="xl:col-span-1 flex flex-col gap-6">
|
||||
|
||||
{/* Location Map */}
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm flex flex-col">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<MapPin size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Location</h3>
|
||||
</div>
|
||||
<div className="bg-slate-100 flex-1 relative min-h-[250px]">
|
||||
{lat && lng ? (
|
||||
<>
|
||||
<iframe
|
||||
title="Store Location Map"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0, position: 'absolute', inset: 0 }}
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`}
|
||||
></iframe>
|
||||
<div
|
||||
className="absolute bottom-3 right-3 w-8 h-8 bg-white/90 backdrop-blur-sm rounded shadow flex items-center justify-center cursor-pointer hover:bg-white transition-colors"
|
||||
onClick={() => window.open(`https://maps.google.com/maps?q=${lat},${lng}`, '_blank')}
|
||||
>
|
||||
<Navigation2 size={16} className="text-slate-700" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-400">
|
||||
<MapPin size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm font-medium">No location data</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lat && lng && (
|
||||
<div className="p-3 bg-white text-[10px] text-slate-500 text-center border-t border-slate-100">
|
||||
Tap map icon to open in Google Maps.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Meta */}
|
||||
<Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
|
||||
<Hash size={16} className="text-slate-400" />
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Meta</h3>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-3 bg-white">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">Store Code</span>
|
||||
<span className="text-xs font-bold text-slate-900">{String(storeCode)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">Instance</span>
|
||||
<span className="text-xs font-bold text-slate-900">#{instanceId}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">Created by</span>
|
||||
<span className="text-xs font-bold text-slate-900">{String(userName)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">Created at</span>
|
||||
<span className="text-xs font-bold text-slate-900">{createdAt !== '-' ? new Date(createdAt as string).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -120,6 +120,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
@ -133,6 +134,51 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
const next = { ...prev, [fieldId]: newVal };
|
||||
if (fullRow) {
|
||||
next[`${fieldId}_row`] = fullRow;
|
||||
|
||||
// Auto-fill mapped fields based on the raw row data only for route_name
|
||||
if (fieldId === 'route_name_2' || fieldId.startsWith('route_name')) {
|
||||
schema?.fields.forEach(f => {
|
||||
if (f.id !== fieldId) {
|
||||
const mappedName = (f.mapped_workflow_field || '').toLowerCase();
|
||||
const nameFallback = (f.name || '').toLowerCase().replace(/\s+/g, '_');
|
||||
const fieldName = mappedName || nameFallback;
|
||||
|
||||
let matchKey = Object.keys(fullRow).find(k => k.toLowerCase() === mappedName || k.toLowerCase() === nameFallback);
|
||||
|
||||
// Special handling for route_name -> distributor details
|
||||
if (!matchKey) {
|
||||
matchKey = Object.keys(fullRow).find(k =>
|
||||
fieldName === `distributor_${k.toLowerCase()}` ||
|
||||
(k.toLowerCase() === 'phone' && fieldName === 'distributor_phone_number') ||
|
||||
(k.toLowerCase() === 'email' && fieldName === 'distributor_email')
|
||||
);
|
||||
}
|
||||
|
||||
if (matchKey && fullRow[matchKey] !== undefined && fullRow[matchKey] !== null) {
|
||||
if (matchKey.toLowerCase() === 'owner_name' && fieldName === 'owner_name') {
|
||||
return;
|
||||
}
|
||||
if (matchKey.toLowerCase() === 'email' && fieldName === 'email') {
|
||||
return;
|
||||
}
|
||||
if (matchKey.toLowerCase() === 'phone' && (fieldName === 'phone' || fieldName === 'phone_number')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fillVal = fullRow[matchKey];
|
||||
// If the target field is a select, try to match option value case-insensitively
|
||||
if (f.data_type === 'select' && f.properties?.options) {
|
||||
const matchedOpt = f.properties.options.find((o: any) => String(o.value).toLowerCase() === String(fillVal).toLowerCase());
|
||||
if (matchedOpt) {
|
||||
fillVal = matchedOpt.value;
|
||||
}
|
||||
}
|
||||
|
||||
next[f.id] = fillVal;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-calculate order_details totals
|
||||
@ -207,20 +253,31 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
const merged: Record<string, unknown>[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
const getGridVal = (obj: Record<string, unknown>, searchTokens: string[], excludeTokens: string[] = []) => {
|
||||
for (const k of Object.keys(obj)) {
|
||||
const lowerK = k.toLowerCase();
|
||||
if (searchTokens.some(t => lowerK.includes(t)) && !excludeTokens.some(t => lowerK.includes(t))) {
|
||||
return obj[k];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (const r of rawGrid) {
|
||||
const cat = String(r.category || r.product_category || '').trim().toLowerCase();
|
||||
const prod = String(r.productName || r.product_name || r.product || '').trim().toLowerCase();
|
||||
const currentBags = Number(r.bags || r.quantity || 0);
|
||||
const cat = String(getGridVal(r, ['category', 'cat']) || '').trim().toLowerCase();
|
||||
const prod = String(getGridVal(r, ['product', 'name'], ['category', 'cat']) || '').trim().toLowerCase();
|
||||
const currentBags = Number(getGridVal(r, ['bags', 'quantity'])) || 0;
|
||||
const currentSku = Number(getGridVal(r, ['sku'], ['code'])) || 0;
|
||||
|
||||
if (cat && prod) {
|
||||
const key = `${cat}::${prod}`;
|
||||
if (indexMap.has(key)) {
|
||||
const targetIdx = indexMap.get(key)!;
|
||||
const existingRow = merged[targetIdx];
|
||||
const existingBags = Number(existingRow.bags || existingRow.quantity || 0);
|
||||
const existingBags = Number(getGridVal(existingRow, ['bags', 'quantity'])) || 0;
|
||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
||||
const newBags = existingBags + addedBags;
|
||||
const skuVal = Number(existingRow.sku || r.sku || 0);
|
||||
const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0);
|
||||
|
||||
merged[targetIdx] = {
|
||||
...existingRow,
|
||||
@ -253,8 +310,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
let totalB = 0;
|
||||
let totalK = 0;
|
||||
cleanGrid.forEach((r) => {
|
||||
const b = Number(r.bags || r.quantity) || 0;
|
||||
const sku = Number(r.sku) || 0;
|
||||
const b = Number(getGridVal(r, ['bags', 'quantity'])) || 0;
|
||||
const sku = Number(getGridVal(r, ['sku'], ['code'])) || 0;
|
||||
totalB += b;
|
||||
totalK += b * sku;
|
||||
});
|
||||
@ -322,16 +379,32 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
|
||||
for (const f of normalFields) {
|
||||
if (f.data_type.startsWith('grid')) {
|
||||
if (currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const gridRows = (finalValues[f.id] as Record<string, unknown>[]) || [];
|
||||
|
||||
const hasProdCol = f.columns?.some(c =>
|
||||
c.id.toLowerCase().includes('product_name') ||
|
||||
c.id.toLowerCase() === 'product' ||
|
||||
c.id.toLowerCase() === 'name' ||
|
||||
(c.name || '').toLowerCase().includes('product name') ||
|
||||
(c.name || '').toLowerCase() === 'product' ||
|
||||
((c.name || '').toLowerCase().includes('product') && !(c.name || '').toLowerCase().includes('category'))
|
||||
);
|
||||
|
||||
// Check if grid has at least one valid row
|
||||
const validRows = gridRows.filter((r) => {
|
||||
const { cat, prod, bags } = extractGridRowDetails(r, f.columns);
|
||||
return cat && prod && bags > 0;
|
||||
if (hasProdCol) {
|
||||
return cat && prod && bags > 0;
|
||||
}
|
||||
return cat && bags > 0;
|
||||
});
|
||||
|
||||
if (validRows.length === 0) {
|
||||
setSubmitError('Please add at least one complete product with Category, Product Name, and Bags.');
|
||||
if (f.mandatory && validRows.length === 0) {
|
||||
setSubmitError(`Please add at least one complete row for ${f.name}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -339,6 +412,12 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
const hasIncompleteRow = gridRows.some((r) => {
|
||||
const { cat, prod, bags } = extractGridRowDetails(r, f.columns);
|
||||
|
||||
if (!hasProdCol) {
|
||||
if (cat && bags <= 0) return true;
|
||||
if (bags > 0 && !cat) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cat && !prod && bags <= 0) return false;
|
||||
if (cat && (!prod || bags <= 0)) return true;
|
||||
if (prod && (!cat || bags <= 0)) return true;
|
||||
@ -347,7 +426,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
});
|
||||
|
||||
if (hasIncompleteRow) {
|
||||
setSubmitError('Please complete all required product details (Product Name and Bags) before submitting.');
|
||||
if (!hasProdCol) {
|
||||
setSubmitError(`Please complete all required details (Category and Quantity) for ${f.name} before submitting.`);
|
||||
} else {
|
||||
setSubmitError('Please complete all required product details (Category, Product Name, and Bags) before submitting.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -398,6 +481,15 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
|
||||
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
|
||||
payload[f.id] = [fileMeta];
|
||||
} else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') {
|
||||
const gridRows = Array.isArray(val) ? val : [];
|
||||
const cleanRows = gridRows.filter(r => {
|
||||
return Object.keys(r).some(k => {
|
||||
const v = r[k];
|
||||
return v !== undefined && v !== null && String(v).trim() !== '';
|
||||
});
|
||||
});
|
||||
payload[f.id] = cleanRows;
|
||||
} else {
|
||||
payload[f.id] = val;
|
||||
}
|
||||
@ -410,6 +502,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
res = await client.startInstance(currentActivityId, payload);
|
||||
}
|
||||
|
||||
setHasSubmitted(true);
|
||||
|
||||
let pending = [...chainQueue];
|
||||
let chainSource = (res as any).activity_chain || schema.activity_chain || [];
|
||||
|
||||
@ -579,7 +673,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
options={f.properties?.options || []}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
onChange={(newVal, fullRow) => handleFieldChange(f.id, newVal, fullRow)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -609,6 +703,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
}
|
||||
|
||||
if (type.startsWith('grid')) {
|
||||
const isPotentialMiningGrid = currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56';
|
||||
return (
|
||||
<SmartGridField
|
||||
label={f.name}
|
||||
@ -618,6 +713,10 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
totalBagsValue={tbField ? Number(values[tbField.id]) || 0 : undefined}
|
||||
totalKgsValue={tkField ? Number(values[tkField.id]) || 0 : undefined}
|
||||
showErrors={showErrors}
|
||||
disableAddRow={isPotentialMiningGrid}
|
||||
hideTotal={isPotentialMiningGrid}
|
||||
readOnlyCols={isPotentialMiningGrid ? ['product_category_', 'actual_potential', 'total_ordered', 'difference'] : []}
|
||||
isPotentialMining={isPotentialMiningGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -718,7 +817,13 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||
<Button type="button" variant="secondary" onClick={() => {
|
||||
if (hasSubmitted) {
|
||||
onSuccess?.();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '../../buttons/Button';
|
||||
|
||||
export function GeolocationInput({
|
||||
@ -15,6 +15,8 @@ export function GeolocationInput({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const autoFetchDone = useRef(false);
|
||||
|
||||
const fetchLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation is not supported by your browser.');
|
||||
@ -38,21 +40,34 @@ export function GeolocationInput({
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!value && !autoFetchDone.current) {
|
||||
autoFetchDone.current = true;
|
||||
fetchLocation();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const mapCenter = value
|
||||
? new L.LatLng(value.latitude, value.longitude)
|
||||
: new L.LatLng(20.5937, 78.9629); // Default to India roughly if no location
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<div className="flex flex-col gap-2 font-sans w-full">
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
|
||||
{loading ? 'Fetching...' : 'Get Location'}
|
||||
</Button>
|
||||
{value && (
|
||||
<span className="text-sm text-strong">
|
||||
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
|
||||
{loading ? 'Fetching...' : 'Re-fetch GPS'}
|
||||
</Button>
|
||||
{value && (
|
||||
<span className="text-sm text-strong bg-slate-100 px-3 py-1.5 rounded-md border border-slate-200">
|
||||
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="text-xs text-ruby-600">{error}</span>}
|
||||
{required && !value && <input type="text" className="sr-only" required />}
|
||||
|
||||
492
src/components/forms/fields/OrderGrid.tsx
Normal file
492
src/components/forms/fields/OrderGrid.tsx
Normal file
@ -0,0 +1,492 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import { isCategoryColumn, isProductColumn, findColumn } from './gridUtils';
|
||||
|
||||
export interface OrderGridProps {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
totalBagsValue?: number;
|
||||
totalKgsValue?: number;
|
||||
showErrors?: boolean;
|
||||
}
|
||||
|
||||
export function OrderGrid({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
totalBagsValue,
|
||||
totalKgsValue,
|
||||
showErrors = false,
|
||||
}: OrderGridProps) {
|
||||
const rows = value.length > 0 ? value : [{}];
|
||||
|
||||
const catCol = useMemo(() => columns.find(c => isCategoryColumn(c.id, c.name)) || findColumn(columns, 'product_category', 'category'), [columns]);
|
||||
const prodNameCol = useMemo(() => columns.find(c => isProductColumn(c.id, c.name)), [columns]);
|
||||
const bagsCol = useMemo(() => findColumn(columns, 'bags', 'quantity', 'actual_potential', 'store_potential'), [columns]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const baseCols = (() => {
|
||||
if (columns.length === 0) {
|
||||
return [
|
||||
{ id: 'product_category', name: 'Category', data_type: 'select' } as FormScreenField,
|
||||
{ id: 'product_name', name: 'Product name', data_type: 'select' } as FormScreenField,
|
||||
{ id: 'bags', name: 'Bags', data_type: 'number' } as FormScreenField,
|
||||
];
|
||||
}
|
||||
const filtered = columns.filter((c) => {
|
||||
return isCategoryColumn(c.id, c.name) || isProductColumn(c.id, c.name) || c.id.includes('bags') || (c.name || '').toLowerCase().includes('bags');
|
||||
});
|
||||
if (filtered.length > 0) return filtered;
|
||||
|
||||
return columns.filter((c) => {
|
||||
const idL = c.id.toLowerCase();
|
||||
const nL = (c.name || '').toLowerCase();
|
||||
return !idL.includes('sku') && !nL.includes('sku') && !idL.includes('br_code') && !nL.includes('br code') && !idL.includes('row_kgs');
|
||||
});
|
||||
})();
|
||||
|
||||
return [
|
||||
...baseCols,
|
||||
{ id: '_row_total_kgs', name: 'Total (kgs)', data_type: 'readonly' } as FormScreenField,
|
||||
];
|
||||
}, [columns]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const numCols = visibleColumns.length;
|
||||
const actionCol = ' auto';
|
||||
if (numCols === 4) return `1.3fr 1.5fr 0.7fr 0.8fr${actionCol}`;
|
||||
if (numCols === 3) return `1.4fr 1.6fr 0.7fr${actionCol}`;
|
||||
if (numCols === 2) return `1.5fr 1.5fr${actionCol}`;
|
||||
return `repeat(${numCols}, minmax(0, 1fr))${actionCol}`;
|
||||
}, [visibleColumns]);
|
||||
|
||||
const consolidateRows = useCallback((rowsList: Record<string, unknown>[]) => {
|
||||
const merged: Record<string, unknown>[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const r of rowsList) {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim().toLowerCase();
|
||||
const prod = String(r.productName || r.product_name || (prodNameCol ? r[prodNameCol.id] : '') || '').trim().toLowerCase();
|
||||
|
||||
const bagsColId = bagsCol ? bagsCol.id : 'bags';
|
||||
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
|
||||
|
||||
if (cat && prod) {
|
||||
const key = `${cat}::${prod}`;
|
||||
if (indexMap.has(key)) {
|
||||
const targetIdx = indexMap.get(key)!;
|
||||
const existingRow = merged[targetIdx];
|
||||
|
||||
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
||||
const newBags = existingBags + addedBags;
|
||||
const skuVal = Number(existingRow.sku || r.sku || 0);
|
||||
|
||||
const updatedRow: Record<string, unknown> = {
|
||||
...existingRow,
|
||||
bags: newBags,
|
||||
quantity: newBags,
|
||||
row_kgs: skuVal * newBags,
|
||||
};
|
||||
if (bagsCol) updatedRow[bagsCol.id] = newBags;
|
||||
|
||||
merged[targetIdx] = updatedRow;
|
||||
continue;
|
||||
} else {
|
||||
indexMap.set(key, merged.length);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(r);
|
||||
}
|
||||
|
||||
return merged.length > 0 ? merged : [{}];
|
||||
}, [catCol, prodNameCol, bagsCol]);
|
||||
|
||||
const isAnyRowIncomplete = useMemo(() => {
|
||||
return rows.some((r) => {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim();
|
||||
if (!cat) return true;
|
||||
|
||||
if (prodNameCol) {
|
||||
const prod = String(r.productName || r.product_name || r[prodNameCol.id] || '').trim();
|
||||
if (!prod) return true;
|
||||
}
|
||||
|
||||
if (bagsCol) {
|
||||
const bags = Number(r.bags || r.quantity || r[bagsCol.id] || 0);
|
||||
if (bags <= 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [rows, catCol, prodNameCol, bagsCol]);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
if (isAnyRowIncomplete) return;
|
||||
|
||||
const consolidated = consolidateRows(rows);
|
||||
const cleanRows = consolidated.map((r) => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange([...cleanRows, {}]);
|
||||
}, [rows, isAnyRowIncomplete, consolidateRows, onChange]);
|
||||
|
||||
const removeRow = useCallback((idx: number) => {
|
||||
if (rows.length > 1) {
|
||||
const next = [...rows];
|
||||
next.splice(idx, 1);
|
||||
|
||||
const cleanRows = next.map(r => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach(k => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange(cleanRows);
|
||||
}
|
||||
}, [rows, onChange]);
|
||||
|
||||
const categoriesList = useMemo(() => {
|
||||
if (catCol?.properties?.options && catCol.properties.options.length > 0) {
|
||||
return catCol.properties.options.map((o: any) => ({
|
||||
value: String(o.value ?? o.label),
|
||||
label: String(o.label ?? o.value),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [catCol]);
|
||||
|
||||
const getProductsForCategory = useCallback((selectedCat?: string) => {
|
||||
let allOpts: { value: string; label: string; rawCat?: string; raw?: any }[] = [];
|
||||
|
||||
if (prodNameCol?.properties?.options && prodNameCol.properties.options.length > 0) {
|
||||
allOpts = prodNameCol.properties.options.map((o: any) => {
|
||||
const val = String(o.value ?? o.label ?? '');
|
||||
const label = String(o.label ?? o.value ?? '');
|
||||
const rawCat = o._raw?.product_category || o._raw?.cat || o._raw?.category;
|
||||
return { value: val, label, rawCat, raw: o._raw };
|
||||
});
|
||||
|
||||
if (selectedCat) {
|
||||
allOpts = allOpts.filter((o) => {
|
||||
if (o.rawCat) {
|
||||
return String(o.rawCat).trim().toLowerCase() === String(selectedCat).trim().toLowerCase();
|
||||
}
|
||||
return o.label.toLowerCase().startsWith(String(selectedCat).toLowerCase());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allOpts;
|
||||
}, [prodNameCol]);
|
||||
|
||||
const updateRowField = useCallback((rowIdx: number, fieldId: string, val: unknown) => {
|
||||
const next = rows.map((r) => ({ ...r }));
|
||||
let row = { ...next[rowIdx] };
|
||||
|
||||
const colDef = columns.find((c) => c.id === fieldId) || visibleColumns.find((c) => c.id === fieldId);
|
||||
const colName = colDef?.name || '';
|
||||
|
||||
const isCatField = isCategoryColumn(fieldId, colName);
|
||||
const isProdField = isProductColumn(fieldId, colName);
|
||||
|
||||
if (isCatField) {
|
||||
const catVal = String(val ?? '');
|
||||
row[fieldId] = catVal;
|
||||
row['category'] = catVal;
|
||||
row['product_category'] = catVal;
|
||||
|
||||
const pColId = prodNameCol?.id || 'product_name';
|
||||
row[pColId] = '';
|
||||
row['product_name'] = '';
|
||||
row['productName'] = '';
|
||||
row['sku'] = '';
|
||||
row['br_code'] = '';
|
||||
row['br'] = '';
|
||||
row['sku_code'] = '';
|
||||
row['skucode'] = '';
|
||||
} else if (isProdField) {
|
||||
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
|
||||
const prodOptions = getProductsForCategory(currentCat);
|
||||
|
||||
const matchedOpt = prodOptions.find(
|
||||
(o) => o.value === String(val) || o.label === String(val) || o.label.toLowerCase() === String(val).toLowerCase()
|
||||
);
|
||||
|
||||
const selectedName = matchedOpt?.label || String(val);
|
||||
const sku = matchedOpt?.raw?.sku || '';
|
||||
const br = matchedOpt?.raw?.br_code || matchedOpt?.raw?.br || matchedOpt?.raw?.brcode || matchedOpt?.raw?.brand_code || '';
|
||||
const skucode = matchedOpt?.raw?.sku_code || matchedOpt?.raw?.skucode || '';
|
||||
const cat = matchedOpt?.rawCat || matchedOpt?.raw?.product_category || currentCat;
|
||||
|
||||
const setVal = matchedOpt?.value || String(val);
|
||||
row[fieldId] = setVal;
|
||||
row['product_name'] = selectedName;
|
||||
row['productName'] = selectedName;
|
||||
if (prodNameCol) row[prodNameCol.id] = setVal;
|
||||
|
||||
row['sku'] = sku;
|
||||
const skuCol = findColumn(columns, 'sku');
|
||||
if (skuCol) row[skuCol.id] = sku;
|
||||
|
||||
row['br'] = br;
|
||||
row['br_code'] = br;
|
||||
const brCol = findColumn(columns, 'br_code', 'br');
|
||||
if (brCol) row[brCol.id] = br;
|
||||
|
||||
row['skucode'] = skucode;
|
||||
row['sku_code'] = skucode;
|
||||
const skuCodeCol = findColumn(columns, 'sku_code', 'skucode');
|
||||
if (skuCodeCol) row[skuCodeCol.id] = skucode;
|
||||
|
||||
if (cat) {
|
||||
row['category'] = cat;
|
||||
row['product_category'] = cat;
|
||||
if (catCol) row[catCol.id] = cat;
|
||||
}
|
||||
} else {
|
||||
row[fieldId] = val;
|
||||
}
|
||||
|
||||
next[rowIdx] = row;
|
||||
|
||||
const cleanRows = next.map((r) => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange(cleanRows);
|
||||
}, [rows, columns, visibleColumns, prodNameCol, catCol, getProductsForCategory, onChange]);
|
||||
|
||||
const { calculatedBags, calculatedKgs } = useMemo(() => {
|
||||
let bagsAcc = 0;
|
||||
let kgsAcc = 0;
|
||||
for (const r of rows) {
|
||||
const b = Number(r.bags ?? r.quantity ?? (bagsCol ? r[bagsCol.id] : 0)) || 0;
|
||||
const sku = Number(r.sku) || 0;
|
||||
bagsAcc += b;
|
||||
kgsAcc += b * sku;
|
||||
}
|
||||
return { calculatedBags: Math.round(bagsAcc), calculatedKgs: Math.round(kgsAcc) };
|
||||
}, [rows, bagsCol]);
|
||||
|
||||
const displayTotalBags = totalBagsValue !== undefined ? totalBagsValue : calculatedBags;
|
||||
const displayTotalKgs = totalKgsValue !== undefined ? totalKgsValue : calculatedKgs;
|
||||
|
||||
return (
|
||||
<div className="max-w-full bg-white rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{label || 'Order details'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<div className="grid gap-3 px-1 pb-2 text-xs font-bold text-muted uppercase tracking-wider" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => (
|
||||
<span key={col.id}>{col.name}</span>
|
||||
))}
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, rowIdx) => {
|
||||
const currentCatVal = String(row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? '');
|
||||
const currentProdVal = String(row[prodNameCol?.id || 'product_name'] ?? row.product_name ?? row.productName ?? '');
|
||||
const currentBagsVal = row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : '') ?? '';
|
||||
|
||||
const prodOptions = getProductsForCategory(currentCatVal);
|
||||
const isCategorySelected = Boolean(currentCatVal.trim());
|
||||
|
||||
const skuNum = Number(row.sku) || 0;
|
||||
const bagsNum = Number(currentBagsVal) || 0;
|
||||
const computedRowKgs = skuNum * bagsNum;
|
||||
|
||||
const isMissingProd = isCategorySelected && !currentProdVal.trim();
|
||||
const prodError = showErrors && isMissingProd ? 'Required' : undefined;
|
||||
|
||||
const isMissingBags = isCategorySelected && (!currentBagsVal || Number(currentBagsVal) <= 0);
|
||||
const bagsError = showErrors && isMissingBags ? 'Required' : undefined;
|
||||
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => {
|
||||
const fieldId = col.id;
|
||||
|
||||
if (fieldId === '_row_total_kgs') {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={computedRowKgs > 0 ? `${computedRowKgs.toLocaleString()} kgs` : '0 kgs'}
|
||||
className="w-full font-semibold text-slate-700 bg-slate-50 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isCat = isCategoryColumn(fieldId, col.name);
|
||||
const isProd = isProductColumn(fieldId, col.name);
|
||||
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isCat) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentCatVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[{ value: '', label: 'Category' }, ...categoriesList]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isProd) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentProdVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
disabled={!isCategorySelected && categoriesList.length > 0}
|
||||
options={[
|
||||
{ value: '', label: isCategorySelected || categoriesList.length === 0 ? 'Select product' : 'Select category first' },
|
||||
...prodOptions
|
||||
]}
|
||||
error={prodError}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBagsOrNum) {
|
||||
const isMainBagsCol = bagsCol?.id === fieldId || fieldId === 'bags' || fieldId === 'quantity';
|
||||
const numVal = isMainBagsCol ? currentBagsVal : row[fieldId];
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="number"
|
||||
min={isMainBagsCol ? "1" : undefined}
|
||||
value={numVal !== undefined && numVal !== null ? String(numVal) : ''}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
placeholder={isMainBagsCol && isCategorySelected ? 'Bags' : '0'}
|
||||
error={isMainBagsCol ? bagsError : undefined}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="text"
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
aria-label="Remove product"
|
||||
disabled={rows.length === 1}
|
||||
className="h-[42px] w-[42px] flex items-center justify-center rounded-md border border-border-default text-slate-500 hover:bg-slate-50 hover:border-ruby-300 hover:text-ruby-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-500 transition-colors shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isAnyRowIncomplete}
|
||||
className="text-xs px-3.5 py-2 rounded-md bg-slate-900 text-white hover:bg-slate-800 font-semibold transition-all shadow-sm flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-slate-900"
|
||||
>
|
||||
+ Add product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bagsCol && (
|
||||
<div className="border-t border-border-subtle pt-3 mt-4">
|
||||
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col, idx) => {
|
||||
const fieldId = col.id;
|
||||
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isBagsOrNum) {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={displayTotalBags.toLocaleString()}
|
||||
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldId === '_row_total_kgs') {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={`${displayTotalKgs.toLocaleString()} kgs`}
|
||||
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
return <div key={col.id} className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider px-1">Total</div>;
|
||||
}
|
||||
|
||||
return <div key={col.id} />;
|
||||
})}
|
||||
<div className="w-[42px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/components/forms/fields/PotentialMiningGrid.tsx
Normal file
187
src/components/forms/fields/PotentialMiningGrid.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import { isCategoryColumn } from './gridUtils';
|
||||
|
||||
export interface PotentialMiningGridProps {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
readOnlyCols?: string[];
|
||||
}
|
||||
|
||||
export function PotentialMiningGrid({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
readOnlyCols = [],
|
||||
}: PotentialMiningGridProps) {
|
||||
const rows = value.length > 0 ? value : [{}];
|
||||
|
||||
const catCol = useMemo(() => columns.find(c => isCategoryColumn(c.id, c.name)), [columns]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
return columns.filter((c) => {
|
||||
const idL = c.id.toLowerCase();
|
||||
const nL = (c.name || '').toLowerCase();
|
||||
return !idL.includes('sku') && !nL.includes('sku') &&
|
||||
!idL.includes('br_code') && !idL.includes('brcode') && !idL.includes('brand_code') &&
|
||||
!nL.includes('br code') && !idL.includes('row_kgs');
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const numCols = visibleColumns.length;
|
||||
if (numCols === 4) return `1.3fr 1.5fr 0.7fr 0.8fr`;
|
||||
if (numCols === 3) return `1.4fr 1.6fr 0.7fr`;
|
||||
if (numCols === 2) return `1.5fr 1.5fr`;
|
||||
return `repeat(${numCols}, minmax(0, 1fr))`;
|
||||
}, [visibleColumns]);
|
||||
|
||||
const categoriesList = useMemo(() => {
|
||||
if (catCol?.properties?.options && catCol.properties.options.length > 0) {
|
||||
return catCol.properties.options.map((o: any) => ({
|
||||
value: String(o.value ?? o.label),
|
||||
label: String(o.label ?? o.value),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [catCol]);
|
||||
|
||||
const updateRowField = useCallback((rowIdx: number, fieldId: string, val: unknown) => {
|
||||
const next = rows.map((r) => ({ ...r }));
|
||||
let row = { ...next[rowIdx] };
|
||||
|
||||
const colDef = columns.find((c) => c.id === fieldId);
|
||||
const colName = colDef?.name || '';
|
||||
const isCatField = isCategoryColumn(fieldId, colName);
|
||||
|
||||
if (isCatField) {
|
||||
const catVal = String(val ?? '');
|
||||
row[fieldId] = catVal;
|
||||
row['category'] = catVal;
|
||||
row['product_category'] = catVal;
|
||||
} else {
|
||||
row[fieldId] = val;
|
||||
}
|
||||
|
||||
next[rowIdx] = row;
|
||||
onChange(next);
|
||||
}, [rows, columns, onChange]);
|
||||
|
||||
return (
|
||||
<div className="max-w-full bg-white rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{label || 'Potential Mining'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<div className="grid gap-3 px-1 pb-2 text-xs font-bold text-muted uppercase tracking-wider" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => (
|
||||
<span key={col.id}>{col.name}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, rowIdx) => {
|
||||
const currentCatVal = String(row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? '');
|
||||
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => {
|
||||
const fieldId = col.id;
|
||||
const isReadOnly = readOnlyCols.some(c => c === fieldId || c.toLowerCase() === col.name?.toLowerCase());
|
||||
|
||||
if (fieldId === 'difference' || col.name?.toLowerCase() === 'difference') {
|
||||
const diffVal = Number(row[fieldId]) || 0;
|
||||
let displayStr = '0 kgs';
|
||||
let textColor = 'text-slate-600';
|
||||
let bgColor = 'bg-slate-50 border-slate-200';
|
||||
|
||||
if (diffVal < 0) {
|
||||
displayStr = `${Math.abs(diffVal)} kgs shortage`;
|
||||
textColor = 'text-rose-600';
|
||||
bgColor = 'bg-rose-50 border-rose-200';
|
||||
} else if (diffVal > 0) {
|
||||
displayStr = `${diffVal} kgs surplus`;
|
||||
textColor = 'text-emerald-600';
|
||||
bgColor = 'bg-emerald-50 border-emerald-200';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={fieldId} className={`flex items-center justify-center h-[42px] px-3 rounded-md border text-xs font-bold w-full ${bgColor} ${textColor}`}>
|
||||
{displayStr}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCat = isCategoryColumn(fieldId, col.name);
|
||||
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isCat) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentCatVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
disabled={isReadOnly}
|
||||
options={[{ value: '', label: 'Category' }, ...categoriesList]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBagsOrNum) {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="number"
|
||||
disabled={isReadOnly}
|
||||
value={row[fieldId] !== undefined && row[fieldId] !== null ? String(row[fieldId]) : ''}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={String(row[fieldId] ?? '')}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="text"
|
||||
disabled={isReadOnly}
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,14 +11,18 @@ export function SelectField({
|
||||
required?: boolean;
|
||||
value: string;
|
||||
options: any[];
|
||||
onChange: (val: string) => void;
|
||||
onChange: (val: string, fullRow?: any) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const opt = options.find((o: any) => String(o.value) === val);
|
||||
onChange(val, opt?._raw);
|
||||
}}
|
||||
options={[{ value: '', label: 'Select...' }, ...options.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,86 +1,7 @@
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
// Product dataset — category, name, sku (kg per unit), br code, sku code
|
||||
export const FALLBACK_PRODUCTS = [
|
||||
{ cat: "ATTA REGULAR", name: "ATTA REGULAR 30 kgs", sku: "30", br: "AR", skucode: "AR30" },
|
||||
{ cat: "ATTA REGULAR", name: "ATTA REGULAR 50 kgs", sku: "50", br: "AR", skucode: "AR50" },
|
||||
{ cat: "ATTA TANDOORI", name: "ATTA TANDOORI 50 kgs", sku: "50", br: "AT", skucode: "AT50" },
|
||||
{ cat: "BANSI RAWA", name: "BANSI RAWA 1kg", sku: "1", br: "BNS", skucode: "BNS-1" },
|
||||
{ cat: "BANSI RAWA", name: "BANSI RAWA 1kg*30", sku: "30", br: "BNS", skucode: "BN13" },
|
||||
{ cat: "BANSI RAWA", name: "BANSI RAWA 30 kgs", sku: "30", br: "BNS", skucode: "BNS30" },
|
||||
{ cat: "CHAKKI ATTA", name: "CHAKKI ATTA 10kg*3", sku: "30", br: "CAP", skucode: "CA103" },
|
||||
{ cat: "CHAKKI ATTA", name: "CHAKKI ATTA 1kg*30", sku: "30", br: "CAP", skucode: "CA13" },
|
||||
{ cat: "CHAKKI ATTA", name: "CHAKKI ATTA 500g*60", sku: "30", br: "CAP", skucode: "CA500" },
|
||||
{ cat: "CHAKKI ATTA", name: "CHAKKI ATTA 5kg*6", sku: "30", br: "CAP", skucode: "CA56" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 500g", sku: "0.5", br: "CAP", skucode: "CAP-500" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 1 kgs", sku: "1", br: "CAP", skucode: "CAP-1" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 5 kgs", sku: "5", br: "CAP", skucode: "CAP-5" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 10 kgs", sku: "10", br: "CAP", skucode: "CAP-10" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 30 kgs", sku: "30", br: "CAP", skucode: "CAP30" },
|
||||
{ cat: "CHAKKI ATTA PREMIUM", name: "CHAKKI ATTA PREMIUM 50 kgs", sku: "50", br: "CAP", skucode: "CAP50" },
|
||||
{ cat: "CHAKKI ATTA SUPER VALUE", name: "CHAKKI ATTA SUPER VALUE 30 kgs", sku: "30", br: "CASV", skucode: "CASV30" },
|
||||
{ cat: "CHAKKI ATTA SUPER VALUE", name: "CHAKKI ATTA SUPER VALUE 50 kgs", sku: "50", br: "CASV", skucode: "CASV50" },
|
||||
{ cat: "CHAKKI ATTA VALUE", name: "CHAKKI ATTA VALUE 30 kgs", sku: "30", br: "CAV", skucode: "CAV30" },
|
||||
{ cat: "CHAKKI ATTA VALUE", name: "CHAKKI ATTA VALUE 50 kgs", sku: "50", br: "CAV", skucode: "CAV50" },
|
||||
{ cat: "CHIROTI", name: "CHIROTI 1kg*30", sku: "30", br: "CPR", skucode: "C13" },
|
||||
{ cat: "CHIROTI", name: "CHIROTI 500g*60", sku: "30", br: "CPR", skucode: "C56" },
|
||||
{ cat: "CHIROTI RAWA", name: "CHIROTI RAWA 500g", sku: "0.5", br: "CR", skucode: "C1-500" },
|
||||
{ cat: "CHIROTI RAWA", name: "CHIROTI RAWA 1kg", sku: "1", br: "CR", skucode: "C1-1" },
|
||||
{ cat: "CHIROTI PREMIUM", name: "CHIROTI PREMIUM 30 kgs", sku: "30", br: "CPR", skucode: "CPR30" },
|
||||
{ cat: "CHIROTI PREMIUM", name: "CHIROTI PREMIUM 50 kgs", sku: "50", br: "CPR", skucode: "CPR50" },
|
||||
{ cat: "DALIYA", name: "DALIYA 500g", sku: "0.5", br: "DALIYA", skucode: "DL-1" },
|
||||
{ cat: "DALIYA", name: "DALIYA 30 kgs", sku: "30", br: "DALIYA", skucode: "DAL30" },
|
||||
{ cat: "DALIYA", name: "DALIYA 500g*40", sku: "20", br: "DALIYA", skucode: "D500" },
|
||||
{ cat: "FORTIFIED MAIDA", name: "FORTIFIED MAIDA 500G", sku: "0.5", br: "MPR", skucode: "MF1-500" },
|
||||
{ cat: "FORTIFIED MAIDA", name: "FORTIFIED MAIDA 1kg", sku: "1", br: "MPR", skucode: "MF1-1" },
|
||||
{ cat: "FORTIFIED MAIDA", name: "FORTIFIED MAIDA 1kg*30", sku: "30", br: "MPR", skucode: "M13" },
|
||||
{ cat: "FORTIFIED MAIDA", name: "FORTIFIED MAIDA 500GX60", sku: "30", br: "MPR", skucode: "M56" },
|
||||
{ cat: "MAIDA ALL PURPOSE", name: "MAIDA ALL PURPOSE 30 kgs", sku: "30", br: "MAP", skucode: "MAP30" },
|
||||
{ cat: "MAIDA ALL PURPOSE", name: "MAIDA ALL PURPOSE 50 kgs", sku: "50", br: "MAP", skucode: "MAP50" },
|
||||
{ cat: "MAIDA BAKERY", name: "MAIDA BAKERY 50 kgs", sku: "50", br: "MB", skucode: "MB50" },
|
||||
{ cat: "MAIDA PREMIUM", name: "MAIDA PREMIUM 30 kgs", sku: "30", br: "MPR", skucode: "MPR30" },
|
||||
{ cat: "MAIDA PREMIUM", name: "MAIDA PREMIUM 50 kgs", sku: "50", br: "MPR", skucode: "MPR50" },
|
||||
{ cat: "MAIDA PREMIUM", name: "MAIDA PREMIUM (TN) 50 kgs", sku: "50", br: "MPRTN", skucode: "MPRTN50" },
|
||||
{ cat: "MAIDA PREMIUM", name: "MAIDA PREMIUM (TN) 30 kgs", sku: "30", br: "MPRTN", skucode: "MPRTN30" },
|
||||
{ cat: "MAIDA PREMIUM", name: "MAIDA PREMIUM (TN) 10X5", sku: "50", br: "MPRTN", skucode: "MPRTN10" },
|
||||
{ cat: "MAIDA PUFF", name: "MAIDA PUFF 30 kgs", sku: "30", br: "PM", skucode: "MP30" },
|
||||
{ cat: "MAIDA PUFF", name: "MAIDA PUFF 50 kgs", sku: "50", br: "PM", skucode: "MP50" },
|
||||
{ cat: "MAIDA SUPER VALUE", name: "MAIDA SUPER VALUE 30 kgs", sku: "30", br: "MSV", skucode: "MSV30" },
|
||||
{ cat: "MAIDA SUPER VALUE", name: "MAIDA SUPER VALUE 50 kgs", sku: "50", br: "MSV", skucode: "MSV50" },
|
||||
{ cat: "SUJI", name: "SUJI 1kg*30", sku: "30", br: "SPR", skucode: "S13" },
|
||||
{ cat: "SUJI", name: "SUJI 500g*60", sku: "30", br: "SPR", skucode: "S56" },
|
||||
{ cat: "SUJI", name: "SUJI 1Kg", sku: "1", br: "SPR", skucode: "S1-1" },
|
||||
{ cat: "SUJI", name: "SUJI 500g", sku: "0.5", br: "SPR", skucode: "S1-500" },
|
||||
{ cat: "SUJI PREMIUM", name: "SUJI PREMIUM 30 kgs", sku: "30", br: "SPR", skucode: "SPR30" },
|
||||
{ cat: "SUJI PREMIUM", name: "SUJI PREMIUM 50 kgs", sku: "50", br: "SPR", skucode: "SPR50" },
|
||||
{ cat: "SUJI SUPER VALUE", name: "SUJI SUPER VALUE 30 kgs", sku: "30", br: "SSV", skucode: "SSV30" },
|
||||
{ cat: "SUJI SUPER VALUE", name: "SUJI SUPER VALUE 50 kgs", sku: "50", br: "SSV", skucode: "SSV50" },
|
||||
{ cat: "SUPER VALUE CHAKKI ATTA", name: "SUPER VALUE CHAKKI ATTA 1kg*30", sku: "30", br: "CASV", skucode: "CASV13" },
|
||||
{ cat: "SUPER VALUE CHAKKI ATTA", name: "SUPER VALUE CHAKKI ATTA 5kg*6", sku: "30", br: "CASV", skucode: "CASV56" },
|
||||
{ cat: "WHEAT BRAN DELUX", name: "WHEAT BRAN DELUX 40 kgs (UNBRANED)", sku: "40", br: "WBD", skucode: "WBD40" },
|
||||
{ cat: "WHEAT BRAN FINE", name: "WHEAT BRAN FINE 45 kgs (UNBRANDED)", sku: "45", br: "WBF", skucode: "WBF45" },
|
||||
{ cat: "WHEAT BRAN FLAKES", name: "WHEAT BRAN FLAKES 34 kgs (UNBRANDED)", sku: "34", br: "WBF", skucode: "WBF34" },
|
||||
{ cat: "WHEAT BRAN WHOLE WHEAT", name: "WHEAT BRAN WHOLE WHEAT 45 kgs (UNBRANDED)", sku: "45", br: "WBW", skucode: "WBWW45" },
|
||||
{ cat: "WHOLE MEAL FLOUR", name: "WHOLE MEAL FLOUR 30 kgs", sku: "30", br: "WMF", skucode: "WMF30" },
|
||||
{ cat: "BESAN", name: "BESAN 500g", sku: "0.5", br: "B5", skucode: "GF-500" },
|
||||
{ cat: "BESAN", name: "BESAN 500g*40", sku: "20", br: "B54", skucode: "B54" },
|
||||
{ cat: "BESAN PREMIUM", name: "BESAN PREMIUM 10kg*2", sku: "20", br: "BP", skucode: "BP102" },
|
||||
{ cat: "BESAN VALUE", name: "BESAN VALUE 10kg*2", sku: "20", br: "BV", skucode: "BV102" },
|
||||
{ cat: "RAGI FLOUR", name: "RAGI FLOUR 1kg", sku: "1", br: "RG", skucode: "RG12" },
|
||||
{ cat: "RAGI FLOUR", name: "RAGI FLOUR 1kg*20", sku: "20", br: "RG", skucode: "RG-1" },
|
||||
{ cat: "RAGI FLOUR", name: "RAGI FLOUR 30 kgs", sku: "30", br: "RG", skucode: "RG30" },
|
||||
{ cat: "IDLI RAWA", name: "IDLI RAWA 1kg", sku: "1", br: "IR", skucode: "IR-1" },
|
||||
{ cat: "IDLI RAWA", name: "IDLI RAWA 1kg*20", sku: "20", br: "IR", skucode: "IR12" },
|
||||
{ cat: "IDLI RAWA", name: "IDLI RAWA 30 kgs", sku: "30", br: "IR", skucode: "IR30" },
|
||||
{ cat: "RICE FLOUR", name: "RICE FLOUR 1kg", sku: "1", br: "RC", skucode: "RC-1" },
|
||||
{ cat: "RICE FLOUR", name: "RICE FLOUR 1kg*20", sku: "20", br: "RC", skucode: "RC12" },
|
||||
{ cat: "RICE FLOUR", name: "RICE FLOUR 30 kgs", sku: "30", br: "RC", skucode: "RC30" },
|
||||
];
|
||||
|
||||
export const FALLBACK_CATEGORIES = Array.from(new Set(FALLBACK_PRODUCTS.map((p) => p.cat)));
|
||||
import { StorePotentialGrid } from './StorePotentialGrid';
|
||||
import { OrderGrid } from './OrderGrid';
|
||||
import { PotentialMiningGrid } from './PotentialMiningGrid';
|
||||
|
||||
export interface SmartGridFieldProps {
|
||||
label: string;
|
||||
@ -90,609 +11,26 @@ export interface SmartGridFieldProps {
|
||||
totalBagsValue?: number;
|
||||
totalKgsValue?: number;
|
||||
showErrors?: boolean;
|
||||
disableAddRow?: boolean;
|
||||
hideTotal?: boolean;
|
||||
readOnlyCols?: string[];
|
||||
isPotentialMining?: boolean; // We will pass this from DynamicForm
|
||||
}
|
||||
|
||||
export function SmartGridField({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
totalBagsValue,
|
||||
totalKgsValue,
|
||||
showErrors = false,
|
||||
}: SmartGridFieldProps) {
|
||||
// Ensure we start with at least 1 empty row if value is empty
|
||||
useEffect(() => {
|
||||
if (!value || value.length === 0) {
|
||||
onChange([{}]);
|
||||
}
|
||||
}, [value, onChange]);
|
||||
export function SmartGridField(props: SmartGridFieldProps) {
|
||||
const { label, isPotentialMining, disableAddRow } = props;
|
||||
|
||||
const rows = value.length > 0 ? value : [{}];
|
||||
|
||||
// Precise column categorization helpers
|
||||
const isCategoryColumn = useCallback((colId: string, colName: string = '') => {
|
||||
const idLower = colId.toLowerCase();
|
||||
const nameLower = colName.toLowerCase();
|
||||
return idLower.includes('category') || nameLower.includes('category');
|
||||
}, []);
|
||||
|
||||
const isProductColumn = useCallback((colId: string, colName: string = '') => {
|
||||
if (isCategoryColumn(colId, colName)) return false;
|
||||
const idLower = colId.toLowerCase();
|
||||
const nameLower = colName.toLowerCase();
|
||||
return (
|
||||
idLower.includes('product_name') ||
|
||||
idLower === 'product' ||
|
||||
idLower === 'name' ||
|
||||
nameLower.includes('product name') ||
|
||||
nameLower === 'product' ||
|
||||
(nameLower.includes('product') && !nameLower.includes('category'))
|
||||
);
|
||||
}, [isCategoryColumn]);
|
||||
|
||||
// Helper function to find a column by various field aliases
|
||||
const findColumn = useCallback(
|
||||
(...aliases: string[]) => {
|
||||
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
||||
return columns.find((c) => {
|
||||
const idLower = c.id.toLowerCase();
|
||||
const nameLower = (c.name || '').toLowerCase();
|
||||
const mappedLower = (c.mapped_workflow_field || '').toLowerCase();
|
||||
const baseIdLower = getBaseId(c.id).toLowerCase();
|
||||
|
||||
return aliases.some((alias) => {
|
||||
const a = alias.toLowerCase();
|
||||
return (
|
||||
idLower === a ||
|
||||
nameLower === a ||
|
||||
mappedLower === a ||
|
||||
baseIdLower === a ||
|
||||
idLower.includes(a) ||
|
||||
nameLower.includes(a)
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
[columns]
|
||||
);
|
||||
|
||||
const catCol = useMemo(() => columns.find(c => isCategoryColumn(c.id, c.name)) || findColumn('product_category', 'category'), [columns, isCategoryColumn, findColumn]);
|
||||
const prodNameCol = useMemo(() => columns.find(c => isProductColumn(c.id, c.name)) || findColumn('product_name', 'name', 'product'), [columns, isProductColumn, findColumn]);
|
||||
const bagsCol = useMemo(() => findColumn('bags', 'quantity', 'actual_potential', 'store_potential'), [findColumn]);
|
||||
|
||||
// Filter columns to display in grid row layout (including virtual display column Total kgs)
|
||||
const visibleColumns = useMemo(() => {
|
||||
const baseCols = (() => {
|
||||
if (columns.length === 0) {
|
||||
return [
|
||||
{ id: 'product_category', name: 'Category', data_type: 'select' } as FormScreenField,
|
||||
{ id: 'product_name', name: 'Product name', data_type: 'select' } as FormScreenField,
|
||||
{ id: 'bags', name: 'Bags', data_type: 'number' } as FormScreenField,
|
||||
];
|
||||
}
|
||||
const isOrderDetails = label.toLowerCase().includes('order') || label.toLowerCase().includes('product');
|
||||
if (isOrderDetails) {
|
||||
const filtered = columns.filter((c) => {
|
||||
return isCategoryColumn(c.id, c.name) || isProductColumn(c.id, c.name) || c.id.includes('bags') || (c.name || '').toLowerCase().includes('bags');
|
||||
});
|
||||
if (filtered.length > 0) return filtered;
|
||||
}
|
||||
return columns.filter((c) => {
|
||||
const idL = c.id.toLowerCase();
|
||||
const nL = (c.name || '').toLowerCase();
|
||||
return !idL.includes('sku') && !nL.includes('sku') && !idL.includes('br_code') && !nL.includes('br code') && !idL.includes('row_kgs');
|
||||
});
|
||||
})();
|
||||
|
||||
const isOrderGrid = label.toLowerCase().includes('order') || label.toLowerCase().includes('product');
|
||||
if (isOrderGrid) {
|
||||
return [
|
||||
...baseCols,
|
||||
{ id: '_row_total_kgs', name: 'Total (kgs)', data_type: 'readonly' } as FormScreenField,
|
||||
];
|
||||
}
|
||||
|
||||
return baseCols;
|
||||
}, [columns, label, isCategoryColumn, isProductColumn]);
|
||||
|
||||
// Determine grid columns template ratio
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const numCols = visibleColumns.length;
|
||||
if (numCols === 4) return '1.3fr 1.5fr 0.7fr 0.8fr auto';
|
||||
if (numCols === 3) return '1.4fr 1.6fr 0.7fr auto';
|
||||
if (numCols === 2) return '1.5fr 1.5fr auto';
|
||||
return `repeat(${numCols}, minmax(0, 1fr)) auto`;
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Consolidation helper to merge duplicate (Category + Product Name) rows by summing bags
|
||||
const consolidateRows = useCallback((rowsList: Record<string, unknown>[]) => {
|
||||
const merged: Record<string, unknown>[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const r of rowsList) {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim().toLowerCase();
|
||||
const prod = String(r.productName || r.product_name || (prodNameCol ? r[prodNameCol.id] : '') || '').trim().toLowerCase();
|
||||
|
||||
const bagsColId = bagsCol ? bagsCol.id : 'bags';
|
||||
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
|
||||
|
||||
if (cat && prod) {
|
||||
const key = `${cat}::${prod}`;
|
||||
if (indexMap.has(key)) {
|
||||
const targetIdx = indexMap.get(key)!;
|
||||
const existingRow = merged[targetIdx];
|
||||
|
||||
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
||||
const newBags = existingBags + addedBags;
|
||||
const skuVal = Number(existingRow.sku || r.sku || 0);
|
||||
|
||||
const updatedRow: Record<string, unknown> = {
|
||||
...existingRow,
|
||||
bags: newBags,
|
||||
quantity: newBags,
|
||||
row_kgs: skuVal * newBags,
|
||||
};
|
||||
if (bagsCol) updatedRow[bagsCol.id] = newBags;
|
||||
|
||||
merged[targetIdx] = updatedRow;
|
||||
continue;
|
||||
} else {
|
||||
indexMap.set(key, merged.length);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(r);
|
||||
}
|
||||
|
||||
return merged.length > 0 ? merged : [{}];
|
||||
}, [catCol, prodNameCol, bagsCol]);
|
||||
|
||||
// Check if any existing row in the grid table is incomplete
|
||||
const isAnyRowIncomplete = useMemo(() => {
|
||||
return rows.some((r) => {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim();
|
||||
const prod = String(r.productName || r.product_name || (prodNameCol ? r[prodNameCol.id] : '') || '').trim();
|
||||
const bags = Number(r.bags || r.quantity || (bagsCol ? r[bagsCol.id] : '') || 0);
|
||||
|
||||
return !cat || !prod || bags <= 0;
|
||||
});
|
||||
}, [rows, catCol, prodNameCol, bagsCol]);
|
||||
|
||||
// When user clicks "+ Add product", verify that ALL existing rows are complete
|
||||
const addRow = useCallback(() => {
|
||||
if (isAnyRowIncomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consolidated = consolidateRows(rows);
|
||||
const cleanRows = consolidated.map((r) => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange([...cleanRows, {}]);
|
||||
}, [rows, isAnyRowIncomplete, consolidateRows, onChange]);
|
||||
|
||||
const removeRow = useCallback(
|
||||
(idx: number) => {
|
||||
if (rows.length > 1) {
|
||||
const next = [...rows];
|
||||
next.splice(idx, 1);
|
||||
|
||||
// Clean client-only virtual keys before notifying parent
|
||||
const cleanRows = next.map(r => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach(k => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange(cleanRows);
|
||||
}
|
||||
},
|
||||
[rows, onChange]
|
||||
);
|
||||
|
||||
// Derive categories list
|
||||
const categoriesList = useMemo(() => {
|
||||
if (catCol?.properties?.options && catCol.properties.options.length > 0) {
|
||||
return catCol.properties.options.map((o: any) => ({
|
||||
value: String(o.value ?? o.label),
|
||||
label: String(o.label ?? o.value),
|
||||
}));
|
||||
}
|
||||
return FALLBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
|
||||
}, [catCol]);
|
||||
|
||||
// Derive products list for a given category (all options selectable)
|
||||
const getProductsForCategory = useCallback(
|
||||
(selectedCat?: string) => {
|
||||
let allOpts: { value: string; label: string; rawCat?: string; raw?: any }[] = [];
|
||||
|
||||
if (prodNameCol?.properties?.options && prodNameCol.properties.options.length > 0) {
|
||||
allOpts = prodNameCol.properties.options.map((o: any) => {
|
||||
const val = String(o.value ?? o.label ?? '');
|
||||
const label = String(o.label ?? o.value ?? '');
|
||||
const rawCat = o._raw?.product_category || o._raw?.cat || o._raw?.category;
|
||||
return { value: val, label, rawCat, raw: o._raw };
|
||||
});
|
||||
|
||||
if (selectedCat) {
|
||||
allOpts = allOpts.filter((o) => {
|
||||
if (o.rawCat) {
|
||||
return String(o.rawCat).trim().toLowerCase() === String(selectedCat).trim().toLowerCase();
|
||||
}
|
||||
return o.label.toLowerCase().startsWith(String(selectedCat).toLowerCase());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback PRODUCTS dataset
|
||||
if (!selectedCat) {
|
||||
allOpts = FALLBACK_PRODUCTS.map((p) => ({
|
||||
value: p.name,
|
||||
label: p.name,
|
||||
rawCat: p.cat,
|
||||
raw: { product_category: p.cat, product_name: p.name, sku: p.sku, br_code: p.br, sku_code: p.skucode },
|
||||
}));
|
||||
} else {
|
||||
allOpts = FALLBACK_PRODUCTS.filter((p) => p.cat.trim().toLowerCase() === String(selectedCat).trim().toLowerCase()).map((p) => ({
|
||||
value: p.name,
|
||||
label: p.name,
|
||||
rawCat: p.cat,
|
||||
raw: { product_category: p.cat, product_name: p.name, sku: p.sku, br_code: p.br, sku_code: p.skucode },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return allOpts;
|
||||
},
|
||||
[prodNameCol]
|
||||
);
|
||||
|
||||
const updateRowField = useCallback(
|
||||
(rowIdx: number, fieldId: string, val: unknown) => {
|
||||
const next = rows.map((r) => ({ ...r }));
|
||||
let row = { ...next[rowIdx] };
|
||||
|
||||
const colDef = columns.find((c) => c.id === fieldId) || visibleColumns.find((c) => c.id === fieldId);
|
||||
const colName = colDef?.name || '';
|
||||
|
||||
const isCatField = isCategoryColumn(fieldId, colName);
|
||||
const isProdField = isProductColumn(fieldId, colName);
|
||||
|
||||
if (isCatField) {
|
||||
const catVal = String(val ?? '');
|
||||
row[fieldId] = catVal;
|
||||
row['category'] = catVal;
|
||||
row['product_category'] = catVal;
|
||||
|
||||
// Reset product name & specs for this row
|
||||
const pColId = prodNameCol?.id || 'product_name';
|
||||
row[pColId] = '';
|
||||
row['product_name'] = '';
|
||||
row['productName'] = '';
|
||||
row['sku'] = '';
|
||||
row['br_code'] = '';
|
||||
row['br'] = '';
|
||||
row['sku_code'] = '';
|
||||
row['skucode'] = '';
|
||||
} else if (isProdField) {
|
||||
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
|
||||
const prodOptions = getProductsForCategory(currentCat);
|
||||
|
||||
const matchedOpt = prodOptions.find(
|
||||
(o) => o.value === String(val) || o.label === String(val) || o.label.toLowerCase() === String(val).toLowerCase()
|
||||
);
|
||||
|
||||
const matchedFallback = FALLBACK_PRODUCTS.find(
|
||||
(p) => p.name === String(val) || p.skucode === String(val) || p.name.toLowerCase() === String(val).toLowerCase()
|
||||
);
|
||||
|
||||
const selectedName = matchedOpt?.label || matchedFallback?.name || String(val);
|
||||
const sku = matchedOpt?.raw?.sku || matchedFallback?.sku || '';
|
||||
const br = matchedOpt?.raw?.br_code || matchedOpt?.raw?.br || matchedFallback?.br || '';
|
||||
const skucode = matchedOpt?.raw?.sku_code || matchedOpt?.raw?.skucode || matchedFallback?.skucode || '';
|
||||
const cat = matchedOpt?.rawCat || matchedOpt?.raw?.product_category || matchedFallback?.cat || currentCat;
|
||||
|
||||
const setVal = matchedOpt?.value || String(val);
|
||||
row[fieldId] = setVal;
|
||||
row['product_name'] = selectedName;
|
||||
row['productName'] = selectedName;
|
||||
if (prodNameCol) row[prodNameCol.id] = setVal;
|
||||
|
||||
row['sku'] = sku;
|
||||
const skuCol = findColumn('sku');
|
||||
if (skuCol) row[skuCol.id] = sku;
|
||||
|
||||
row['br'] = br;
|
||||
row['br_code'] = br;
|
||||
const brCol = findColumn('br_code', 'br');
|
||||
if (brCol) row[brCol.id] = br;
|
||||
|
||||
row['skucode'] = skucode;
|
||||
row['sku_code'] = skucode;
|
||||
const skuCodeCol = findColumn('sku_code', 'skucode');
|
||||
if (skuCodeCol) row[skuCodeCol.id] = skucode;
|
||||
|
||||
if (cat) {
|
||||
row['category'] = cat;
|
||||
row['product_category'] = cat;
|
||||
if (catCol) row[catCol.id] = cat;
|
||||
}
|
||||
} else {
|
||||
row[fieldId] = val;
|
||||
}
|
||||
|
||||
next[rowIdx] = row;
|
||||
|
||||
// Clean client-only virtual keys before sending to parent
|
||||
const cleanRows = next.map((r) => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
onChange(cleanRows);
|
||||
},
|
||||
[rows, columns, visibleColumns, prodNameCol, catCol, findColumn, isCategoryColumn, isProductColumn, getProductsForCategory, onChange]
|
||||
);
|
||||
|
||||
// Calculate overall totals across all rows
|
||||
const { calculatedBags, calculatedKgs } = useMemo(() => {
|
||||
let bagsAcc = 0;
|
||||
let kgsAcc = 0;
|
||||
for (const r of rows) {
|
||||
const b = Number(r.bags ?? r.quantity) || 0;
|
||||
const sku = Number(r.sku) || 0;
|
||||
bagsAcc += b;
|
||||
kgsAcc += b * sku;
|
||||
}
|
||||
return { calculatedBags: Math.round(bagsAcc), calculatedKgs: Math.round(kgsAcc) };
|
||||
}, [rows]);
|
||||
|
||||
const displayTotalBags = totalBagsValue !== undefined ? totalBagsValue : calculatedBags;
|
||||
const displayTotalKgs = totalKgsValue !== undefined ? totalKgsValue : calculatedKgs;
|
||||
// Use PotentialMiningGrid if explicitly requested or if we're disabling add row (as a fallback heuristic)
|
||||
if (isPotentialMining || (disableAddRow && label.toLowerCase().includes('potential'))) {
|
||||
return <PotentialMiningGrid {...props} />;
|
||||
}
|
||||
|
||||
// Use OrderGrid if it's an order/product details grid
|
||||
const isOrderGrid = label.toLowerCase().includes('order') || label.toLowerCase().includes('product');
|
||||
if (isOrderGrid) {
|
||||
return <OrderGrid {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-full bg-white rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{label || 'Order details'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isAnyRowIncomplete}
|
||||
className="text-xs px-3.5 py-2 rounded-md border border-border-default hover:bg-slate-50 font-medium text-strong transition-all shadow-sm flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
>
|
||||
+ {isOrderGrid ? 'Add product' : 'Add row'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid Table Header */}
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<div
|
||||
className="grid gap-3 px-1 pb-2 text-xs font-bold text-muted uppercase tracking-wider"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<span key={col.id}>{col.name}</span>
|
||||
))}
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* Rows List */}
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, rowIdx) => {
|
||||
const currentCatVal = String(
|
||||
row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? ''
|
||||
);
|
||||
const currentProdVal = String(
|
||||
row[prodNameCol?.id || 'product_name'] ?? row.product_name ?? row.productName ?? ''
|
||||
);
|
||||
const currentBagsVal = row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : '') ?? '';
|
||||
|
||||
const prodOptions = getProductsForCategory(currentCatVal);
|
||||
const isCategorySelected = Boolean(currentCatVal.trim());
|
||||
|
||||
// Compute row total kgs = sku * bags for display
|
||||
const skuNum = Number(row.sku) || 0;
|
||||
const bagsNum = Number(currentBagsVal) || 0;
|
||||
const computedRowKgs = skuNum * bagsNum;
|
||||
|
||||
// Only display red "Required" error badge when showErrors is true (on form submit)
|
||||
const isMissingProd = isCategorySelected && !currentProdVal.trim();
|
||||
const prodError = showErrors && isMissingProd ? 'Required' : undefined;
|
||||
|
||||
const isMissingBags = isCategorySelected && (!currentBagsVal || Number(currentBagsVal) <= 0);
|
||||
const bagsError = showErrors && isMissingBags ? 'Required' : undefined;
|
||||
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div
|
||||
className="grid gap-3 items-center"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{visibleColumns.map((col) => {
|
||||
const fieldId = col.id;
|
||||
|
||||
if (fieldId === '_row_total_kgs') {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={computedRowKgs > 0 ? `${computedRowKgs.toLocaleString()} kgs` : '0 kgs'}
|
||||
className="w-full font-semibold text-slate-700 bg-slate-50 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isCat = isCategoryColumn(fieldId, col.name);
|
||||
const isProd = isProductColumn(fieldId, col.name);
|
||||
const isBagsOrNum =
|
||||
col.data_type === 'number' ||
|
||||
fieldId.includes('bags') ||
|
||||
fieldId.includes('quantity') ||
|
||||
col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isCat) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentCatVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Category' },
|
||||
...categoriesList
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isProd) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentProdVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
disabled={!isCategorySelected && categoriesList.length > 0}
|
||||
options={[
|
||||
{ value: '', label: isCategorySelected || categoriesList.length === 0 ? 'Select product' : 'Select category first' },
|
||||
...prodOptions
|
||||
]}
|
||||
error={prodError}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBagsOrNum) {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="number"
|
||||
min="1"
|
||||
value={currentBagsVal !== undefined && currentBagsVal !== null ? String(currentBagsVal) : ''}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
placeholder={isCategorySelected ? 'Bags' : '0'}
|
||||
error={bagsError}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic Select or Text Input
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select...' },
|
||||
...opts.map((o: any) => ({ value: String(o.value), label: o.label }))
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="text"
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Remove Row Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
aria-label="Remove product"
|
||||
disabled={rows.length === 1}
|
||||
className="h-[42px] w-[42px] flex items-center justify-center rounded-md border border-border-default text-slate-500 hover:bg-slate-50 hover:border-ruby-300 hover:text-ruby-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-500 transition-colors shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid Footer - Positions the actual total_bags and total_kgs form fields right under Bags & Total kgs columns */}
|
||||
<div className="border-t border-border-subtle pt-3 mt-4">
|
||||
<div
|
||||
className="grid gap-3 items-center"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{visibleColumns.map((col, idx) => {
|
||||
const fieldId = col.id;
|
||||
const isBagsOrNum =
|
||||
col.data_type === 'number' ||
|
||||
fieldId.includes('bags') ||
|
||||
fieldId.includes('quantity') ||
|
||||
col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isBagsOrNum) {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={displayTotalBags.toLocaleString()}
|
||||
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldId === '_row_total_kgs') {
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
disabled
|
||||
value={`${displayTotalKgs.toLocaleString()} kgs`}
|
||||
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
return (
|
||||
<div key={col.id} className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider px-1">
|
||||
Total
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={col.id} />;
|
||||
})}
|
||||
<div className="w-[42px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Use StorePotentialGrid for simpler grids like Store adding potential
|
||||
return <StorePotentialGrid {...props} />;
|
||||
}
|
||||
|
||||
254
src/components/forms/fields/StorePotentialGrid.tsx
Normal file
254
src/components/forms/fields/StorePotentialGrid.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import { isCategoryColumn, findColumn } from './gridUtils';
|
||||
|
||||
export interface StorePotentialGridProps {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
showErrors?: boolean;
|
||||
}
|
||||
|
||||
export function StorePotentialGrid({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
showErrors = false,
|
||||
}: StorePotentialGridProps) {
|
||||
const rows = value.length > 0 ? value : [{}];
|
||||
|
||||
const catCol = useMemo(() => columns.find(c => isCategoryColumn(c.id, c.name)), [columns]);
|
||||
const bagsCol = useMemo(() => findColumn(columns, 'bags', 'quantity', 'actual_potential', 'store_potential'), [columns]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
return columns.filter((c) => {
|
||||
const idL = c.id.toLowerCase();
|
||||
const nL = (c.name || '').toLowerCase();
|
||||
const isBrCode = idL.includes('br_code') || idL.includes('brcode') || idL.includes('brand_code') || nL.includes('br code') || nL.includes('brcode') || nL.includes('brand code');
|
||||
return !idL.includes('sku') && !nL.includes('sku') && !isBrCode && !idL.includes('row_kgs');
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const numCols = visibleColumns.length;
|
||||
const actionCol = ' auto';
|
||||
if (numCols === 4) return `1.3fr 1.5fr 0.7fr 0.8fr${actionCol}`;
|
||||
if (numCols === 3) return `1.4fr 1.6fr 0.7fr${actionCol}`;
|
||||
if (numCols === 2) return `1.5fr 1.5fr${actionCol}`;
|
||||
return `repeat(${numCols}, minmax(0, 1fr))${actionCol}`;
|
||||
}, [visibleColumns]);
|
||||
|
||||
const isAnyRowIncomplete = useMemo(() => {
|
||||
return rows.some((r) => {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim();
|
||||
if (!cat) return true;
|
||||
|
||||
if (bagsCol) {
|
||||
const bags = Number(r.bags || r.quantity || r[bagsCol.id] || 0);
|
||||
if (bags <= 0) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [rows, catCol, bagsCol]);
|
||||
|
||||
const consolidateRows = useCallback((rowsList: Record<string, unknown>[]) => {
|
||||
const merged: Record<string, unknown>[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const r of rowsList) {
|
||||
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim().toLowerCase();
|
||||
|
||||
const bagsColId = bagsCol ? bagsCol.id : 'quantity';
|
||||
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
|
||||
|
||||
if (cat) {
|
||||
if (indexMap.has(cat)) {
|
||||
const targetIdx = indexMap.get(cat)!;
|
||||
const existingRow = merged[targetIdx];
|
||||
|
||||
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
|
||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
||||
const newBags = existingBags + addedBags;
|
||||
|
||||
const updatedRow: Record<string, unknown> = {
|
||||
...existingRow,
|
||||
bags: newBags,
|
||||
quantity: newBags,
|
||||
};
|
||||
if (bagsCol) updatedRow[bagsCol.id] = newBags;
|
||||
|
||||
merged[targetIdx] = updatedRow;
|
||||
continue;
|
||||
} else {
|
||||
indexMap.set(cat, merged.length);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(r);
|
||||
}
|
||||
|
||||
return merged.length > 0 ? merged : [{}];
|
||||
}, [catCol, bagsCol]);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
if (isAnyRowIncomplete) return;
|
||||
const consolidated = consolidateRows(rows);
|
||||
onChange([...consolidated, {}]);
|
||||
}, [rows, isAnyRowIncomplete, consolidateRows, onChange]);
|
||||
|
||||
const removeRow = useCallback((idx: number) => {
|
||||
if (rows.length > 1) {
|
||||
const next = [...rows];
|
||||
next.splice(idx, 1);
|
||||
onChange(next);
|
||||
}
|
||||
}, [rows, onChange]);
|
||||
|
||||
const categoriesList = useMemo(() => {
|
||||
if (catCol?.properties?.options && catCol.properties.options.length > 0) {
|
||||
return catCol.properties.options.map((o: any) => ({
|
||||
value: String(o.value ?? o.label),
|
||||
label: String(o.label ?? o.value),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}, [catCol]);
|
||||
|
||||
const updateRowField = useCallback((rowIdx: number, fieldId: string, val: unknown) => {
|
||||
const next = rows.map((r) => ({ ...r }));
|
||||
let row = { ...next[rowIdx] };
|
||||
const colDef = columns.find((c) => c.id === fieldId);
|
||||
const colName = colDef?.name || '';
|
||||
const isCatField = isCategoryColumn(fieldId, colName);
|
||||
|
||||
if (isCatField) {
|
||||
const catVal = String(val ?? '');
|
||||
row[fieldId] = catVal;
|
||||
row['category'] = catVal;
|
||||
row['product_category'] = catVal;
|
||||
} else {
|
||||
row[fieldId] = val;
|
||||
}
|
||||
|
||||
next[rowIdx] = row;
|
||||
onChange(next);
|
||||
}, [rows, columns, onChange]);
|
||||
|
||||
return (
|
||||
<div className="max-w-full bg-white rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{label || 'Store Potential'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<div className="grid gap-3 px-1 pb-2 text-xs font-bold text-muted uppercase tracking-wider" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => (
|
||||
<span key={col.id}>{col.name}</span>
|
||||
))}
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, rowIdx) => {
|
||||
const currentCatVal = String(row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? '');
|
||||
const currentBagsVal = row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : '') ?? '';
|
||||
const isCategorySelected = Boolean(currentCatVal.trim());
|
||||
const isMissingBags = isCategorySelected && (!currentBagsVal || Number(currentBagsVal) <= 0);
|
||||
const bagsError = showErrors && isMissingBags ? 'Required' : undefined;
|
||||
|
||||
return (
|
||||
<div key={rowIdx}>
|
||||
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
|
||||
{visibleColumns.map((col) => {
|
||||
const fieldId = col.id;
|
||||
const isCat = isCategoryColumn(fieldId, col.name);
|
||||
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
|
||||
|
||||
if (isCat) {
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={currentCatVal}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[{ value: '', label: 'Category' }, ...categoriesList]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBagsOrNum) {
|
||||
const isMainBagsCol = bagsCol?.id === fieldId || fieldId === 'bags' || fieldId === 'quantity';
|
||||
const numVal = isMainBagsCol ? currentBagsVal : row[fieldId];
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="number"
|
||||
min={isMainBagsCol ? "1" : undefined}
|
||||
value={numVal !== undefined && numVal !== null ? String(numVal) : ''}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
placeholder={isMainBagsCol && isCategorySelected ? 'Quantity' : '0'}
|
||||
error={isMainBagsCol ? bagsError : undefined}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={fieldId}
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={fieldId}
|
||||
type="text"
|
||||
value={String(row[fieldId] ?? '')}
|
||||
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
aria-label="Remove row"
|
||||
disabled={rows.length === 1}
|
||||
className="h-[42px] w-[42px] flex items-center justify-center rounded-md border border-border-default text-slate-500 hover:bg-slate-50 hover:border-ruby-300 hover:text-ruby-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-500 transition-colors shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isAnyRowIncomplete}
|
||||
className="text-xs px-3.5 py-2 rounded-md bg-slate-900 text-white hover:bg-slate-800 font-semibold transition-all shadow-sm flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-slate-900"
|
||||
>
|
||||
+ Add row
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/forms/fields/gridUtils.ts
Normal file
43
src/components/forms/fields/gridUtils.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
|
||||
export const isCategoryColumn = (colId: string, colName: string = '') => {
|
||||
const idLower = colId.toLowerCase();
|
||||
const nameLower = colName.toLowerCase();
|
||||
return idLower.includes('category') || nameLower.includes('category');
|
||||
};
|
||||
|
||||
export const isProductColumn = (colId: string, colName: string = '') => {
|
||||
if (isCategoryColumn(colId, colName)) return false;
|
||||
const idLower = colId.toLowerCase();
|
||||
const nameLower = colName.toLowerCase();
|
||||
return (
|
||||
idLower.includes('product_name') ||
|
||||
idLower === 'product' ||
|
||||
idLower === 'name' ||
|
||||
nameLower.includes('product name') ||
|
||||
nameLower === 'product' ||
|
||||
(nameLower.includes('product') && !nameLower.includes('category'))
|
||||
);
|
||||
};
|
||||
|
||||
export const findColumn = (columns: FormScreenField[], ...aliases: string[]) => {
|
||||
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
||||
return columns.find((c) => {
|
||||
const idLower = c.id.toLowerCase();
|
||||
const nameLower = (c.name || '').toLowerCase();
|
||||
const mappedLower = (c.mapped_workflow_field || '').toLowerCase();
|
||||
const baseIdLower = getBaseId(c.id).toLowerCase();
|
||||
|
||||
return aliases.some((alias) => {
|
||||
const a = alias.toLowerCase();
|
||||
return (
|
||||
idLower === a ||
|
||||
nameLower === a ||
|
||||
mappedLower === a ||
|
||||
baseIdLower === a ||
|
||||
idLower.includes(a) ||
|
||||
nameLower.includes(a)
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -40,7 +40,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6'];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 w-full">
|
||||
{charts.map((chart, idx) => {
|
||||
const title = (chart.key || `Chart ${idx + 1}`)
|
||||
.replace(/_/g, ' ')
|
||||
|
||||
@ -4,7 +4,7 @@ import { CallsView } from '../components/rv';
|
||||
import { CallDetail } from '../components/dv';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, ArrowLeft } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { ORDER_BOOKING } from '../api/config';
|
||||
import { orderBookingClient } from '../api/clients';
|
||||
@ -80,54 +80,14 @@ export function CallsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallsView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
setSelectedRow(row);
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/calls/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => {
|
||||
setCreateTitle('Log Visit');
|
||||
setIsCreating(true);
|
||||
}}>
|
||||
Log Visit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title={createTitle}
|
||||
width="md"
|
||||
>
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
initialActivityName="Log Visit"
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onActivityChange={setCreateTitle}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null}
|
||||
onClose={() => navigate(`/calls`)}
|
||||
title={instanceId != null ? `Call #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && selectedRow && (
|
||||
<div className="pb-4">
|
||||
if (instanceId != null) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
{selectedRow ? (
|
||||
<CallDetail
|
||||
instanceId={instanceId}
|
||||
onBack={() => navigate('/calls')}
|
||||
selectedRow={selectedRow}
|
||||
refreshKey={refreshKey}
|
||||
potentialMiningAction={
|
||||
@ -167,32 +127,75 @@ export function CallsPage() {
|
||||
})()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="p-8 text-center text-slate-500">Loading call details...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Activity Modal (for actions) */}
|
||||
<Modal
|
||||
open={activeActivity != null}
|
||||
onClose={() => setActiveActivity(null)}
|
||||
title={activeActivity?.name}
|
||||
width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'}
|
||||
>
|
||||
{activeActivity && instanceId != null && (
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={activeActivity.id}
|
||||
instanceId={instanceId}
|
||||
initialActivityName={activeActivity.name}
|
||||
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
|
||||
customPrefillData={activeActivity.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? miningPrefill : undefined}
|
||||
onSuccess={() => {
|
||||
setActiveActivity(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setActiveActivity(null)}
|
||||
onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallsView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
setSelectedRow(row);
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/calls/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => {
|
||||
setCreateTitle('Log Visit');
|
||||
setIsCreating(true);
|
||||
}}>
|
||||
Log Visit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={activeActivity != null}
|
||||
onClose={() => setActiveActivity(null)}
|
||||
title={activeActivity?.name}
|
||||
width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'}
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title={createTitle}
|
||||
width="md"
|
||||
>
|
||||
{activeActivity && instanceId != null && (
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={activeActivity.id}
|
||||
instanceId={instanceId}
|
||||
initialActivityName={activeActivity.name}
|
||||
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
|
||||
customPrefillData={activeActivity.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? miningPrefill : undefined}
|
||||
onSuccess={() => {
|
||||
setActiveActivity(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setActiveActivity(null)}
|
||||
onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)}
|
||||
/>
|
||||
)}
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
initialActivityName="Log Visit"
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onActivityChange={setCreateTitle}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -21,36 +21,38 @@ export function DailyLogsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DailyLogsView
|
||||
refreshKey={refreshKey}
|
||||
presetAlias="my_logs"
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/daily/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => {
|
||||
setCreateTitle('Punch In');
|
||||
setIsCreating(true);
|
||||
}}>
|
||||
Punch In
|
||||
</Button>
|
||||
}
|
||||
rowActions={(row) => {
|
||||
const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase();
|
||||
// Show only for Punched In, hide for Punched Out or others
|
||||
if (state.includes('out') || !state.includes('in')) return null;
|
||||
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
setPunchOutTitle('Punch Out');
|
||||
setPunchOutInstanceId(row.instance_id as string | number);
|
||||
{instanceId == null && (
|
||||
<DailyLogsView
|
||||
refreshKey={refreshKey}
|
||||
presetAlias="my_logs"
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/daily/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => {
|
||||
setCreateTitle('Punch In');
|
||||
setIsCreating(true);
|
||||
}}>
|
||||
Punch Out
|
||||
Punch In
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rowActions={(row) => {
|
||||
const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase();
|
||||
// Show only for Punched In, hide for Punched Out or others
|
||||
if (state.includes('out') || !state.includes('in')) return null;
|
||||
|
||||
return (
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
setPunchOutTitle('Punch Out');
|
||||
setPunchOutInstanceId(row.instance_id as string | number);
|
||||
}}>
|
||||
Punch Out
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
@ -93,14 +95,16 @@ export function DailyLogsPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null}
|
||||
onClose={() => navigate(`/daily`)}
|
||||
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && <DailyLogDetail instanceId={instanceId} onPunchOut={() => setPunchOutInstanceId(instanceId)} />}
|
||||
</Modal>
|
||||
{instanceId != null && (
|
||||
<div className="w-full">
|
||||
<DailyLogDetail
|
||||
instanceId={instanceId}
|
||||
refreshKey={refreshKey}
|
||||
onPunchOut={() => setPunchOutInstanceId(instanceId)}
|
||||
onBack={() => navigate('/daily')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -157,28 +157,24 @@ export function DailySalesReportPage() {
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Route"
|
||||
value={route}
|
||||
onChange={(e) => setRoute(e.target.value)}
|
||||
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES.map(r => ({ value: r, label: r }))]}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Distributor"
|
||||
value={distributor}
|
||||
onChange={(e) => setDistributor(e.target.value)}
|
||||
options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS.map(d => ({ value: d, label: d }))]}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="SO Name"
|
||||
value={soName}
|
||||
onChange={(e) => setSoName(e.target.value)}
|
||||
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES.map(s => ({ value: s, label: s }))]}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Modal } from '../components/reusable';
|
||||
import { StoresView } from '../components/rv';
|
||||
import { StoreDetail } from '../components/dv';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
@ -8,6 +7,7 @@ import { Plus } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { STORE } from '../api/config';
|
||||
import { storeClient } from '../api/clients';
|
||||
import { Modal } from '../components/reusable';
|
||||
|
||||
export function StoresPage() {
|
||||
const params = useParams();
|
||||
@ -19,6 +19,42 @@ export function StoresPage() {
|
||||
const [editTitle, setEditTitle] = useState('Edit Store');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
if (instanceId != null) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<StoreDetail
|
||||
instanceId={instanceId}
|
||||
onBack={() => navigate('/stores')}
|
||||
onEdit={() => setEditingInstanceId(instanceId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={editingInstanceId != null}
|
||||
onClose={() => setEditingInstanceId(null)}
|
||||
title={editTitle}
|
||||
width="md"
|
||||
>
|
||||
{editingInstanceId != null && (
|
||||
<DynamicForm
|
||||
client={storeClient}
|
||||
activityId={STORE.activities.EDIT_STORE.uid}
|
||||
instanceId={editingInstanceId}
|
||||
initialActivityName="Edit Store"
|
||||
onSuccess={() => {
|
||||
setEditingInstanceId(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setEditingInstanceId(null)}
|
||||
onActivityChange={setEditTitle}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoresView
|
||||
@ -85,15 +121,6 @@ export function StoresPage() {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null && !isCreating}
|
||||
onClose={() => navigate(`/stores`)}
|
||||
title={instanceId != null ? `Store #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && <StoreDetail instanceId={instanceId} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user