detail view and forms made changes

This commit is contained in:
suryacp23 2026-07-24 12:56:03 +05:30
parent 77c596e3a1
commit 265cde7f8c
19 changed files with 3226 additions and 1828 deletions

49
package-lock.json generated
View File

@ -8,11 +8,14 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.9.2" "recharts": "^3.9.2"
}, },
@ -589,6 +592,17 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@ -1251,6 +1265,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1258,6 +1278,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.13.2", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@ -2605,6 +2634,12 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -3184,6 +3219,20 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/react-redux": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",

View File

@ -10,11 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@types/leaflet": "^1.9.21",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.9.2" "recharts": "^3.9.2"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -4,16 +4,17 @@ import { DAILY_REPORTS, APP_ID } from '../../api/config';
import { Card } from '../reusable/Card'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState'; 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 { Button } from '../buttons/Button';
import { Map, User, ClipboardList, Camera, Clock } from 'lucide-react';
export interface DailyLogDetailProps { export interface DailyLogDetailProps {
instanceId: number | string; instanceId: number | string;
refreshKey?: number;
onPunchOut?: () => void; onPunchOut?: () => void;
onBack?: () => void;
} }
export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps) { export function DailyLogDetail({ instanceId, refreshKey, onPunchOut, onBack }: DailyLogDetailProps) {
const [data, setData] = useState<Record<string, unknown> | null>(null); const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -34,27 +35,27 @@ export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps)
} }
run(); run();
return () => { live = false; }; return () => { live = false; };
}, [instanceId]); }, [instanceId, refreshKey]);
if (error) { if (error) {
return ( return (
<Card title="Daily Log Details"> <Card title={`Daily Log #${instanceId}`}>
<EmptyState title="Couldnt load record" hint={error} /> <EmptyState title={`Couldn't load record`} hint={error} />
</Card> </Card>
); );
} }
if (loading) { if (loading) {
return ( return (
<Card title="Daily Log Details"> <Card title={`Daily Log #${instanceId}`}>
<div className="py-8 flex justify-center"><Spinner label="Loading details" /></div> <div className="py-8 flex justify-center"><Spinner label="Loading details..." /></div>
</Card> </Card>
); );
} }
if (!data || Object.keys(data).length === 0) { if (!data || Object.keys(data).length === 0) {
return ( return (
<Card title="Daily Log Details"> <Card title={`Daily Log #${instanceId}`}>
<EmptyState title="No details found" /> <EmptyState title="No details found" />
</Card> </Card>
); );
@ -64,179 +65,321 @@ export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps)
const extract = (key: string, obj: any = data) => { const extract = (key: string, obj: any = data) => {
if (obj && key in obj) { if (obj && key in obj) {
const val = obj[key]; const val = obj[key];
delete obj[key];
return val; return val;
} }
return '-'; return null;
}; };
// State // State
const stateName = extract('current_state_name', remainingData) as string; const stateName = extract('current_state_name', remainingData) as string || '-';
delete remainingData.current_state_id;
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');
// General Info // Date and Time
const date = extract('date', remainingData); let dateRaw = extract('date', remainingData);
const time = extract('time', remainingData); let timeRaw = extract('time', remainingData);
const routeCode = extract('route_code', remainingData); let formattedDate = '-';
const subRoute = extract('sub_route', remainingData); if (dateRaw) {
const dayPlanNotes = extract('day_plan_notes', remainingData); formattedDate = new Date(dateRaw as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
// Officer Info // Format punch in text
const officerNameRaw = extract('sales_officer_name', remainingData); const punchInTimeText = timeRaw ? `Punch-in ${timeRaw}` : 'Punch-in time unknown';
let officerName = '-';
let officerEmail = '-'; // Route Info
if (officerNameRaw && typeof officerNameRaw === 'object') { const routeCode = extract('route_code', remainingData) || '-';
officerName = (officerNameRaw as any).name || '-'; const subRoute = extract('sub_route', remainingData) || '-';
officerEmail = (officerNameRaw as any).email || '-';
// Notes & Details
const dayPlanNotes = extract('day_plan_notes', 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;
// 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';
// 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');
} }
// Image // Image
const storeImage = extract('store_image', remainingData); const storeImage = extract('store_image', remainingData);
const imageFiles = Array.isArray(storeImage) ? storeImage : [];
// EOD Info
const totalProductive = extract('total_productive_calls', remainingData);
const totalNonProductive = extract('total_non_productive_calls', remainingData);
const eodNotes = extract('eod_notes_remarks', 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 }: { 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 ( return (
<div className="flex flex-col gap-6"> <div className="w-full max-w-[1000px] mx-auto flex flex-col gap-6 pb-12">
{/* 1. Status Card */} {/* Top Header Card */}
<Card pad={false} className={`border-t-[4px] shadow-md ${isPunchedOut ? 'border-emerald-500' : 'border-[var(--z-bg-primary)]'}`}> <div className="border border-slate-200 rounded-xl p-6 shadow-sm bg-white">
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50"> <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>
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Current State</div> <div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Day Plan</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}> <div className="text-3xl font-black text-slate-900 mb-2">{formattedDate}</div>
{stateName} <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>
<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>
</div> </div>
{isPunchedIn && onPunchOut && ( {isPunchedIn && onPunchOut && (
<Button onClick={onPunchOut}>Punch Out</Button> <Button onClick={onPunchOut}>
Punch Out
</Button>
)} )}
</div> </div>
</Card> </div>
{/* 2. Route & Plan Card */} {/* KPI Row & Progress Bar (Only show if Punched Out) */}
<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>
{/* 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 && ( {!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"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<ClipboardList className="text-[var(--z-bg-primary)]" size={18} /> <div className="border border-slate-200 rounded-xl p-4 flex flex-col justify-between">
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">End of Day Details</h3> <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>
<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> </div>
</Card>
)}
{/* 5. Image Card */} <div className="flex items-center justify-between text-[10px] text-slate-500 mb-2">
{Array.isArray(storeImage) && storeImage.length > 0 && ( <span>Productive vs Non Productive</span>
<Card pad={false} className="shadow-md"> <span>{String(prodCalls)} / {totalCalls}</span>
<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>
<div className="p-5"> <div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex">
<div className="flex flex-wrap gap-4"> {totalCalls > 0 && (
{storeImage.map((file: any, idx: number) => { <>
<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>
{/* Split Content */}
<div className="flex flex-col lg:flex-row gap-6">
{/* 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 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`; const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`;
return ( 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"> <div key={file.uuid || idx} className="rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
<img <img src={previewUrl} alt="Store" className="w-full h-auto object-cover" />
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> </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>
</div>
</Card>
)} )}
<div className="text-[10px] text-slate-400 mt-2">Uploaded at punch in.</div>
{/* 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>
<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;
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> </div>
</Card>
)}
{/* 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> </div>
); );
} }

View File

@ -4,9 +4,7 @@ import { ORDER_BOOKING } from '../../api/config';
import { Card } from '../reusable/Card'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState'; import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format'; import { Store, User, Package, Calendar, Hash, Mail, FileText, Weight, List, MapPin } from 'lucide-react';
import { ShoppingCart, Store, User, Package, Clock, ClipboardList } from 'lucide-react';
import { GridTable } from './GridTable';
export interface WiredDetailViewProps { export interface WiredDetailViewProps {
instanceId: number | string; instanceId: number | string;
@ -38,23 +36,23 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
if (error) { if (error) {
return ( return (
<Card title="Order Details"> <Card title={`Order #${instanceId}`}>
<EmptyState title="Couldnt load record" hint={error} /> <EmptyState title={`Couldn't load record`} hint={error} />
</Card> </Card>
); );
} }
if (loading) { if (loading) {
return ( return (
<Card title="Order Details"> <Card title={`Order #${instanceId}`}>
<div className="py-8 flex justify-center"><Spinner label="Loading details" /></div> <div className="py-8 flex justify-center"><Spinner label="Loading details..." /></div>
</Card> </Card>
); );
} }
if (!data || Object.keys(data).length === 0) { if (!data || Object.keys(data).length === 0) {
return ( return (
<Card title="Order Details"> <Card title={`Order #${instanceId}`}>
<EmptyState title="No details found" /> <EmptyState title="No details found" />
</Card> </Card>
); );
@ -67,28 +65,34 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
delete obj[key]; delete obj[key];
return val; return val;
} }
return '-'; return null;
}; };
// State // State
const stateName = extract('current_state_name', remainingData); const stateName = extract('current_state_name', remainingData) || 'Ordered';
delete remainingData.current_state_id; 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 // Order Details
const orderId = extract('order_id', remainingData); const orderId = extract('order_id', remainingData) || '-';
const dateOfOrder = extract('date_of_order_3', 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 // SO Info
const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name'; const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name';
const soRaw = extract(soKey, remainingData); const soRaw = extract(soKey, remainingData);
let soName = '-'; let soName = '-';
let soEmail = '-'; let soEmail = '-';
let soId = '-';
if (soRaw && typeof soRaw === 'object') { if (soRaw && typeof soRaw === 'object') {
soName = (soRaw as any).name || '-'; soName = (soRaw as any).name || '-';
soEmail = (soRaw as any).email || '-'; 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 // Store Info
const storeRaw = extract('select_store', remainingData); const storeRaw = extract('select_store', remainingData);
@ -105,144 +109,171 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) {
// Line Items // Line Items
const orderDetailsGrid = extract('order_details_3', remainingData); const orderDetailsGrid = extract('order_details_3', remainingData);
const itemsArray = Array.isArray(orderDetailsGrid) ? orderDetailsGrid : [];
// Totals // Totals
const totalBags = extract('total_bags_3', remainingData); const totalBags = extract('total_bags_3', remainingData) || 0;
const totalKgs = extract('total_kgs_3', remainingData); const totalKgs = extract('total_kgs_3', remainingData) || 0;
// 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>
);
};
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4 bg-white p-1">
{/* Top Section */}
{/* 1. Status Card */} <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">
<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>
<div className="text-xs font-bold text-muted uppercase tracking-wider mb-1">Status</div> <div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Order ID</div>
<div className={`text-2xl font-black uppercase tracking-tight ${colorClass}`}> <div className="text-2xl font-black text-slate-900 mb-2">{String(orderId)}</div>
{stateName} <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> </div>
</div> </div>
</Card>
{/* 2. Order Summary Card */} {/* Middle Stats Section */}
<Card pad={false} className="shadow-md"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50"> <div className="border border-slate-200 rounded-xl p-5 bg-white shadow-sm flex flex-col justify-between">
<ShoppingCart className="text-[var(--z-bg-primary)]" size={18} /> <div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-3">
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Order Summary</h3> <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>
<div className="p-5 flex flex-col">
<Row label="Order ID" value={orderId} />
<Row label="Date of Order" value={dateOfOrder} />
</div> </div>
</Card>
{/* 3. Store details Card */} {/* Bottom Section */}
<Card pad={false} className="shadow-md"> <div className="flex flex-col lg:flex-row gap-4">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50"> {/* Left Table */}
<Store className="text-[var(--z-bg-primary)]" size={18} /> <div className="flex-[2] border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden flex flex-col min-w-0">
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Details</h3> <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 className="p-5 flex flex-col"> <div className="overflow-x-auto flex-1">
<Row label="Business Name" value={storeName} /> <table className="w-full text-sm text-left">
<Row label="Distributor" value={distributorName} /> <thead className="bg-slate-50/50 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
<Row label="Route" value={`${routeName} (${routeCode})`} /> <tr>
</div> <th className="px-5 py-3 border-b border-slate-100">Product</th>
</Card> <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` : '-';
{/* 4. Sales Officer Card */} return (
<Card pad={false} className="shadow-md"> <tr key={idx} className="hover:bg-slate-50/50 transition-colors">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50"> <td className="px-5 py-3">
<User className="text-[var(--z-bg-primary)]" size={18} /> <div className="font-bold text-slate-900 text-xs mb-0.5 whitespace-nowrap">{String(product)}</div>
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Sales Officer</h3> <div className="text-[10px] text-slate-500">BR Code {String(brCode)}</div>
</div> </td>
<div className="p-5 flex flex-col"> <td className="px-5 py-3 text-xs text-slate-600 whitespace-nowrap">{String(category)}</td>
<Row label="Name" value={soName} /> <td className="px-5 py-3">
<Row label="Email" value={soEmail} /> <div className="text-xs text-slate-600 mb-0.5 whitespace-nowrap">{String(sku)}</div>
</div> <div className="text-[10px] text-slate-500 whitespace-nowrap">{String(weight)}</div>
</Card> </td>
<td className="px-5 py-3 text-right font-semibold text-slate-900 text-xs whitespace-nowrap">{String(bags)}</td>
{/* 5. Line Items Grid */} </tr>
{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} />;
})} })}
<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> </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> </div>
); );
} }

View File

@ -4,12 +4,16 @@ import { STORE, APP_ID } from '../../api/config';
import { Card } from '../reusable/Card'; import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner'; import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState'; import { EmptyState } from '../reusable/EmptyState';
import { formatValue } from '../../lib/format'; import { ArrowLeft, Store, User, Truck, MapPin, TrendingUp, Phone, Navigation2, Edit3, Navigation, Mail, FileText, Hash } from 'lucide-react';
import { Store, User, Truck, MapPin, TrendingUp, Camera, ClipboardList } from 'lucide-react'; import { Button } from '../buttons/Button';
import { GridTable } from './GridTable';
import type { WiredDetailViewProps } from './OrderDetail';
export function StoreDetail({ instanceId }: WiredDetailViewProps) { 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 [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -63,29 +67,33 @@ export function StoreDetail({ instanceId }: WiredDetailViewProps) {
delete obj[key]; delete obj[key];
return val; return val;
} }
return '-'; return null;
}; };
// State // State
const stateName = extract('current_state_name', remainingData); const stateName = extract('current_state_name', remainingData) || '-';
delete remainingData.current_state_id; delete remainingData.current_state_id;
const isSuccess = ['created', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s)); const isSuccess = ['created', 'active', 'approve'].some(s => String(stateName).toLowerCase().includes(s));
const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500'; const badgeClass = isSuccess ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700';
// Store Overview // Store Overview
const storeCode = extract('store_code', remainingData); const storeCode = extract('store_code', remainingData) || '-';
const businessName = extract('business_name', remainingData); const businessName = extract('business_name', remainingData) || '-';
const area = extract('area', remainingData); const area = extract('area', remainingData);
const completeAddress = extract('complete_address', remainingData); const completeAddress = extract('complete_address', remainingData);
const pinCode = extract('pin_code', remainingData); const pinCode = extract('pin_code', remainingData);
const notes = extract('notes', remainingData);
// Owner Info // Owner Info
const ownerName = extract('owner_name', remainingData); const ownerName = extract('owner_name', remainingData) || '-';
const email = extract('email', remainingData); const email = extract('email', remainingData);
let phoneNumber = extract('phone_number', remainingData); let phoneNumber = extract('phone_number', remainingData);
let dialPhone = '';
if (typeof phoneNumber === 'object' && phoneNumber !== null) { 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 || '-'; phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-';
} else {
phoneNumber = phoneNumber || '-';
dialPhone = String(phoneNumber);
} }
// Distributor Info // Distributor Info
@ -95,6 +103,8 @@ export function StoreDetail({ instanceId }: WiredDetailViewProps) {
let distributorPhone = extract('distributor_phone_number', remainingData); let distributorPhone = extract('distributor_phone_number', remainingData);
if (typeof distributorPhone === 'object' && distributorPhone !== null) { if (typeof distributorPhone === 'object' && distributorPhone !== null) {
distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-'; distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-';
} else {
distributorPhone = distributorPhone || '-';
} }
// Route Info // Route Info
@ -119,9 +129,19 @@ export function StoreDetail({ instanceId }: WiredDetailViewProps) {
// Potential // Potential
const potential = extract('potential', remainingData); const potential = extract('potential', remainingData);
let totalPotential = 0;
if (Array.isArray(potential)) {
potential.forEach((p: any) => {
totalPotential += (Number(p.quantity) || 0);
});
}
// Image // Image
const storeImage = extract('store_image', remainingData); 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 // Meta
const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at'));
@ -131,188 +151,315 @@ export function StoreDetail({ instanceId }: WiredDetailViewProps) {
const userObj = userIdKey ? extract(userIdKey, remainingData) : null; const userObj = userIdKey ? extract(userIdKey, remainingData) : null;
const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-'; 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 ( return (
<div className="flex flex-col sm:flex-row sm:items-center py-2 border-b border-border-subtle last:border-0"> <div className="flex flex-col gap-6 w-full max-w-full">
<span className="text-sm text-muted sm:w-1/3">{label}:</span> {/* Top Bar */}
<span className="text-sm font-bold text-slate-800 sm:w-2/3">{formatValue(finalValue)}</span> <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> </div>
);
};
return ( {/* Hero Card */}
<div className="flex flex-col gap-6"> <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col md:flex-row">
{/* Image Section */}
{/* 1. Status Card */} {imageUrl ? (
<Card pad={false} className="border-t-[4px] border-[var(--z-bg-primary)] shadow-md"> <div className="md:w-1/3 lg:w-1/4 h-48 md:h-auto relative bg-slate-100">
<div className="p-5 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/50"> <img src={imageUrl} alt={String(businessName)} className="w-full h-full object-cover" />
<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 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> </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 */} {/* Content Section */}
{Array.isArray(storeImage) && storeImage.length > 0 && ( <div className="flex-1 flex flex-col">
<Card pad={false} className="shadow-md"> <div className="p-6 flex-1">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50"> <div className="flex items-center gap-3 mb-4">
<Camera className="text-[var(--z-bg-primary)]" size={18} /> <div className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1.5 ${badgeClass}`}>
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Image</h3> <span className="w-1.5 h-1.5 rounded-full bg-current opacity-75"></span>
{String(stateName)}
</div> </div>
<div className="p-5"> <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">
<div className="flex flex-wrap gap-4"> <Hash size={12} />
{storeImage.map((file: any, idx: number) => { {String(storeCode)}
const previewUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; </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 ( 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"> <tr key={idx} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<img <td className="py-3 px-5 font-semibold text-slate-900">{p.product_category ? String(p.product_category) : '-'}</td>
src={previewUrl} <td className="py-3 px-5 text-slate-700 text-right">{p.quantity ? String(p.quantity) : '0'}</td>
alt={file.original_name || 'Store'} </tr>
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> <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> </div>
</Card> </Card>
)} )}
{/* 7.5 Store Location Map */} {/* Distributor Card */}
{lat && lng && ( <Card pad={false} className="overflow-hidden border-slate-200 shadow-sm">
<Card pad={false} className="shadow-md"> <div className="flex items-center gap-2 p-4 border-b border-slate-100 bg-white">
<div className="flex items-center gap-2 p-4 border-b border-border-subtle bg-slate-50/50"> <Truck size={16} className="text-slate-400" />
<MapPin className="text-[var(--z-bg-primary)]" size={18} /> <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider m-0">Distributor</h3>
<h3 className="text-lg font-bold text-[var(--z-bg-primary)] m-0">Store Location</h3>
</div> </div>
<div className="p-0"> <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 <iframe
title="Store Location Map" title="Store Location Map"
width="100%" width="100%"
height="350" height="100%"
style={{ border: 0 }} style={{ border: 0, position: 'absolute', inset: 0 }}
loading="lazy" loading="lazy"
allowFullScreen allowFullScreen
src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`} src={`https://maps.google.com/maps?q=${lat},${lng}&hl=en&z=15&output=embed`}
></iframe> ></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> </div>
</Card> </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>
<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> </div>
</Card>
)}
</div> </div>
); );

View File

@ -120,6 +120,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const [values, setValues] = useState<Record<string, unknown>>({}); const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
@ -133,6 +134,51 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const next = { ...prev, [fieldId]: newVal }; const next = { ...prev, [fieldId]: newVal };
if (fullRow) { if (fullRow) {
next[`${fieldId}_row`] = 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 // Auto-calculate order_details totals
@ -207,20 +253,31 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const merged: Record<string, unknown>[] = []; const merged: Record<string, unknown>[] = [];
const indexMap = new Map<string, number>(); 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) { for (const r of rawGrid) {
const cat = String(r.category || r.product_category || '').trim().toLowerCase(); const cat = String(getGridVal(r, ['category', 'cat']) || '').trim().toLowerCase();
const prod = String(r.productName || r.product_name || r.product || '').trim().toLowerCase(); const prod = String(getGridVal(r, ['product', 'name'], ['category', 'cat']) || '').trim().toLowerCase();
const currentBags = Number(r.bags || r.quantity || 0); const currentBags = Number(getGridVal(r, ['bags', 'quantity'])) || 0;
const currentSku = Number(getGridVal(r, ['sku'], ['code'])) || 0;
if (cat && prod) { if (cat && prod) {
const key = `${cat}::${prod}`; const key = `${cat}::${prod}`;
if (indexMap.has(key)) { if (indexMap.has(key)) {
const targetIdx = indexMap.get(key)!; const targetIdx = indexMap.get(key)!;
const existingRow = merged[targetIdx]; 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 addedBags = currentBags > 0 ? currentBags : 1;
const newBags = existingBags + addedBags; const newBags = existingBags + addedBags;
const skuVal = Number(existingRow.sku || r.sku || 0); const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0);
merged[targetIdx] = { merged[targetIdx] = {
...existingRow, ...existingRow,
@ -253,8 +310,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
let totalB = 0; let totalB = 0;
let totalK = 0; let totalK = 0;
cleanGrid.forEach((r) => { cleanGrid.forEach((r) => {
const b = Number(r.bags || r.quantity) || 0; const b = Number(getGridVal(r, ['bags', 'quantity'])) || 0;
const sku = Number(r.sku) || 0; const sku = Number(getGridVal(r, ['sku'], ['code'])) || 0;
totalB += b; totalB += b;
totalK += b * sku; totalK += b * sku;
}); });
@ -322,16 +379,32 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
for (const f of normalFields) { for (const f of normalFields) {
if (f.data_type.startsWith('grid')) { if (f.data_type.startsWith('grid')) {
if (currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56') {
continue;
}
const gridRows = (finalValues[f.id] as Record<string, unknown>[]) || []; 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 // Check if grid has at least one valid row
const validRows = gridRows.filter((r) => { const validRows = gridRows.filter((r) => {
const { cat, prod, bags } = extractGridRowDetails(r, f.columns); const { cat, prod, bags } = extractGridRowDetails(r, f.columns);
if (hasProdCol) {
return cat && prod && bags > 0; return cat && prod && bags > 0;
}
return cat && bags > 0;
}); });
if (validRows.length === 0) { if (f.mandatory && validRows.length === 0) {
setSubmitError('Please add at least one complete product with Category, Product Name, and Bags.'); setSubmitError(`Please add at least one complete row for ${f.name}.`);
return; return;
} }
@ -339,6 +412,12 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const hasIncompleteRow = gridRows.some((r) => { const hasIncompleteRow = gridRows.some((r) => {
const { cat, prod, bags } = extractGridRowDetails(r, f.columns); 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 false;
if (cat && (!prod || bags <= 0)) return true; if (cat && (!prod || bags <= 0)) return true;
if (prod && (!cat || bags <= 0)) return true; if (prod && (!cat || bags <= 0)) return true;
@ -347,7 +426,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
}); });
if (hasIncompleteRow) { 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; 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) { } 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 }); const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
payload[f.id] = [fileMeta]; 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 { } else {
payload[f.id] = val; payload[f.id] = val;
} }
@ -410,6 +502,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
res = await client.startInstance(currentActivityId, payload); res = await client.startInstance(currentActivityId, payload);
} }
setHasSubmitted(true);
let pending = [...chainQueue]; let pending = [...chainQueue];
let chainSource = (res as any).activity_chain || schema.activity_chain || []; let chainSource = (res as any).activity_chain || schema.activity_chain || [];
@ -579,7 +673,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
required={f.mandatory} required={f.mandatory}
value={(val as string) ?? ''} value={(val as string) ?? ''}
options={f.properties?.options || []} 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')) { if (type.startsWith('grid')) {
const isPotentialMiningGrid = currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56';
return ( return (
<SmartGridField <SmartGridField
label={f.name} label={f.name}
@ -618,6 +713,10 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
totalBagsValue={tbField ? Number(values[tbField.id]) || 0 : undefined} totalBagsValue={tbField ? Number(values[tbField.id]) || 0 : undefined}
totalKgsValue={tkField ? Number(values[tkField.id]) || 0 : undefined} totalKgsValue={tkField ? Number(values[tkField.id]) || 0 : undefined}
showErrors={showErrors} 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"> <div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle">
{onCancel && ( {onCancel && (
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}> <Button type="button" variant="secondary" onClick={() => {
if (hasSubmitted) {
onSuccess?.();
} else {
onCancel();
}
}} disabled={submitting}>
Cancel Cancel
</Button> </Button>
)} )}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Button } from '../../buttons/Button'; import { Button } from '../../buttons/Button';
export function GeolocationInput({ export function GeolocationInput({
@ -15,6 +15,8 @@ export function GeolocationInput({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const autoFetchDone = useRef(false);
const fetchLocation = () => { const fetchLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setError('Geolocation is not supported by your browser.'); setError('Geolocation is not supported by your browser.');
@ -38,22 +40,35 @@ 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 ( 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"> <span className="text-sm font-medium text-muted">
{label} {label}
{required && <span className="text-ruby-600"> *</span>} {required && <span className="text-ruby-600"> *</span>}
</span> </span>
<div className="flex items-center gap-3"> <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}> <Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
{loading ? 'Fetching...' : 'Get Location'} {loading ? 'Fetching...' : 'Re-fetch GPS'}
</Button> </Button>
{value && ( {value && (
<span className="text-sm text-strong"> <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)} {value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
</span> </span>
)} )}
</div> </div>
</div>
{error && <span className="text-xs text-ruby-600">{error}</span>} {error && <span className="text-xs text-ruby-600">{error}</span>}
{required && !value && <input type="text" className="sr-only" required />} {required && !value && <input type="text" className="sr-only" required />}
</div> </div>

View 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>
);
}

View 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>
);
}

View File

@ -11,14 +11,18 @@ export function SelectField({
required?: boolean; required?: boolean;
value: string; value: string;
options: any[]; options: any[];
onChange: (val: string) => void; onChange: (val: string, fullRow?: any) => void;
}) { }) {
return ( return (
<Select <Select
label={label} label={label}
required={required} required={required}
value={value ?? ''} 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 }))]} options={[{ value: '', label: 'Select...' }, ...options.map((o: any) => ({ value: String(o.value), label: o.label }))]}
/> />
); );

View File

@ -1,86 +1,7 @@
import { useMemo, useCallback, useEffect } from 'react';
import type { FormScreenField } from '../../../api/types'; import type { FormScreenField } from '../../../api/types';
import { Select } from '../../reusable/Select'; import { StorePotentialGrid } from './StorePotentialGrid';
import { Input } from '../../reusable/Input'; import { OrderGrid } from './OrderGrid';
import { PotentialMiningGrid } from './PotentialMiningGrid';
// 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)));
export interface SmartGridFieldProps { export interface SmartGridFieldProps {
label: string; label: string;
@ -90,609 +11,26 @@ export interface SmartGridFieldProps {
totalBagsValue?: number; totalBagsValue?: number;
totalKgsValue?: number; totalKgsValue?: number;
showErrors?: boolean; showErrors?: boolean;
disableAddRow?: boolean;
hideTotal?: boolean;
readOnlyCols?: string[];
isPotentialMining?: boolean; // We will pass this from DynamicForm
} }
export function SmartGridField({ export function SmartGridField(props: SmartGridFieldProps) {
label, const { label, isPotentialMining, disableAddRow } = props;
columns,
value = [], // Use PotentialMiningGrid if explicitly requested or if we're disabling add row (as a fallback heuristic)
onChange, if (isPotentialMining || (disableAddRow && label.toLowerCase().includes('potential'))) {
totalBagsValue, return <PotentialMiningGrid {...props} />;
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]);
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');
});
})();
// Use OrderGrid if it's an order/product details grid
const isOrderGrid = label.toLowerCase().includes('order') || label.toLowerCase().includes('product'); const isOrderGrid = label.toLowerCase().includes('order') || label.toLowerCase().includes('product');
if (isOrderGrid) { if (isOrderGrid) {
return [ return <OrderGrid {...props} />;
...baseCols,
{ id: '_row_total_kgs', name: 'Total (kgs)', data_type: 'readonly' } as FormScreenField,
];
} }
return baseCols; // Use StorePotentialGrid for simpler grids like Store adding potential
}, [columns, label, isCategoryColumn, isProductColumn]); return <StorePotentialGrid {...props} />;
// 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;
const isOrderGrid = label.toLowerCase().includes('order') || label.toLowerCase().includes('product');
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>
);
} }

View 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>
);
}

View 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)
);
});
});
};

View File

@ -40,7 +40,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) {
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6']; const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6'];
return ( 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) => { {charts.map((chart, idx) => {
const title = (chart.key || `Chart ${idx + 1}`) const title = (chart.key || `Chart ${idx + 1}`)
.replace(/_/g, ' ') .replace(/_/g, ' ')

View File

@ -4,7 +4,7 @@ import { CallsView } from '../components/rv';
import { CallDetail } from '../components/dv'; import { CallDetail } from '../components/dv';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button } from '../components/buttons/Button'; import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react'; import { Plus, ArrowLeft } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm'; import { DynamicForm } from '../components/forms/DynamicForm';
import { ORDER_BOOKING } from '../api/config'; import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients'; import { orderBookingClient } from '../api/clients';
@ -80,54 +80,14 @@ export function CallsPage() {
} }
}; };
if (instanceId != null) {
return ( return (
<> <div className="w-full">
<CallsView <div>
refreshKey={refreshKey} {selectedRow ? (
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">
<CallDetail <CallDetail
instanceId={instanceId} instanceId={instanceId}
onBack={() => navigate('/calls')}
selectedRow={selectedRow} selectedRow={selectedRow}
refreshKey={refreshKey} refreshKey={refreshKey}
potentialMiningAction={ potentialMiningAction={
@ -167,10 +127,12 @@ export function CallsPage() {
})() })()
} }
/> />
</div> ) : (
<div className="p-8 text-center text-slate-500">Loading call details...</div>
)} )}
</Modal> </div>
{/* Active Activity Modal (for actions) */}
<Modal <Modal
open={activeActivity != null} open={activeActivity != null}
onClose={() => setActiveActivity(null)} onClose={() => setActiveActivity(null)}
@ -194,6 +156,47 @@ export function CallsPage() {
/> />
)} )}
</Modal> </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={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>
</> </>
); );
} }

View File

@ -21,6 +21,7 @@ export function DailyLogsPage() {
return ( return (
<> <>
{instanceId == null && (
<DailyLogsView <DailyLogsView
refreshKey={refreshKey} refreshKey={refreshKey}
presetAlias="my_logs" presetAlias="my_logs"
@ -51,6 +52,7 @@ export function DailyLogsPage() {
); );
}} }}
/> />
)}
<Modal <Modal
open={isCreating} open={isCreating}
@ -93,14 +95,16 @@ export function DailyLogsPage() {
)} )}
</Modal> </Modal>
<Modal {instanceId != null && (
open={instanceId != null} <div className="w-full">
onClose={() => navigate(`/daily`)} <DailyLogDetail
title={instanceId != null ? `Daily Log #${instanceId}` : undefined} instanceId={instanceId}
width="lg" refreshKey={refreshKey}
> onPunchOut={() => setPunchOutInstanceId(instanceId)}
{instanceId != null && <DailyLogDetail instanceId={instanceId} onPunchOut={() => setPunchOutInstanceId(instanceId)} />} onBack={() => navigate('/daily')}
</Modal> />
</div>
)}
</> </>
); );
} }

View File

@ -157,28 +157,24 @@ export function DailySalesReportPage() {
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
required
/> />
<Select <Select
label="Route" label="Route"
value={route} value={route}
onChange={(e) => setRoute(e.target.value)} onChange={(e) => setRoute(e.target.value)}
options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES.map(r => ({ value: r, label: r }))]} options={[{ value: '', label: 'Select Route' }, ...SALES_REPORT_ROUTES.map(r => ({ value: r, label: r }))]}
required
/> />
<Select <Select
label="Distributor" label="Distributor"
value={distributor} value={distributor}
onChange={(e) => setDistributor(e.target.value)} onChange={(e) => setDistributor(e.target.value)}
options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS.map(d => ({ value: d, label: d }))]} options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS.map(d => ({ value: d, label: d }))]}
required
/> />
<Select <Select
label="SO Name" label="SO Name"
value={soName} value={soName}
onChange={(e) => setSoName(e.target.value)} onChange={(e) => setSoName(e.target.value)}
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES.map(s => ({ value: s, label: s }))]} 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>} {error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}

View File

@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { StoresView } from '../components/rv'; import { StoresView } from '../components/rv';
import { StoreDetail } from '../components/dv'; import { StoreDetail } from '../components/dv';
import { Button } from '../components/buttons/Button'; import { Button } from '../components/buttons/Button';
@ -8,6 +7,7 @@ import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm'; import { DynamicForm } from '../components/forms/DynamicForm';
import { STORE } from '../api/config'; import { STORE } from '../api/config';
import { storeClient } from '../api/clients'; import { storeClient } from '../api/clients';
import { Modal } from '../components/reusable';
export function StoresPage() { export function StoresPage() {
const params = useParams(); const params = useParams();
@ -19,6 +19,42 @@ export function StoresPage() {
const [editTitle, setEditTitle] = useState('Edit Store'); const [editTitle, setEditTitle] = useState('Edit Store');
const [refreshKey, setRefreshKey] = useState(0); 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 ( return (
<> <>
<StoresView <StoresView
@ -85,15 +121,6 @@ export function StoresPage() {
/> />
)} )}
</Modal> </Modal>
<Modal
open={instanceId != null && !isCreating}
onClose={() => navigate(`/stores`)}
title={instanceId != null ? `Store #${instanceId}` : undefined}
width="lg"
>
{instanceId != null && <StoreDetail instanceId={instanceId} />}
</Modal>
</> </>
); );
} }