From 265cde7f8cad23ed6d4b021800514b4b3339fa41 Mon Sep 17 00:00:00 2001 From: suryacp23 Date: Fri, 24 Jul 2026 12:56:03 +0530 Subject: [PATCH] detail view and forms made changes --- package-lock.json | 49 + package.json | 3 + src/components/dv/CallDetail.tsx | 1217 ++++++++++++----- src/components/dv/DailyLogDetail.tsx | 565 +++++--- src/components/dv/OrderDetail.tsx | 319 +++-- src/components/dv/StoreDetail.tsx | 769 ++++++----- src/components/forms/DynamicForm.tsx | 131 +- .../forms/fields/GeolocationInput.tsx | 37 +- src/components/forms/fields/OrderGrid.tsx | 492 +++++++ .../forms/fields/PotentialMiningGrid.tsx | 187 +++ src/components/forms/fields/SelectField.tsx | 8 +- .../forms/fields/SmartGridField.tsx | 700 +--------- .../forms/fields/StorePotentialGrid.tsx | 254 ++++ src/components/forms/fields/gridUtils.ts | 43 + src/components/reusable/AnalyticsChart.tsx | 2 +- src/screens/CallsPage.tsx | 143 +- src/screens/DailyLogsPage.tsx | 76 +- src/screens/DailySalesReportPage.tsx | 10 +- src/screens/StoresPage.tsx | 49 +- 19 files changed, 3226 insertions(+), 1828 deletions(-) create mode 100644 src/components/forms/fields/OrderGrid.tsx create mode 100644 src/components/forms/fields/PotentialMiningGrid.tsx create mode 100644 src/components/forms/fields/StorePotentialGrid.tsx create mode 100644 src/components/forms/fields/gridUtils.ts diff --git a/package-lock.json b/package-lock.json index 4688ab5..a1ac328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@types/leaflet": "^1.9.21", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", + "leaflet": "^1.9.4", "lucide-react": "^0.400.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.18.1", "recharts": "^3.9.2" }, @@ -589,6 +592,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -1251,6 +1265,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1258,6 +1278,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -2605,6 +2634,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3184,6 +3219,20 @@ "license": "MIT", "peer": true }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", diff --git a/package.json b/package.json index af4561e..295173d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@types/leaflet": "^1.9.21", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", + "leaflet": "^1.9.4", "lucide-react": "^0.400.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.18.1", "recharts": "^3.9.2" }, diff --git a/src/components/dv/CallDetail.tsx b/src/components/dv/CallDetail.tsx index 4cfbd9f..91fc939 100644 --- a/src/components/dv/CallDetail.tsx +++ b/src/components/dv/CallDetail.tsx @@ -1,23 +1,46 @@ import { useEffect, useState, type ReactNode } from 'react'; import { orderBookingClient } from '../../api/clients'; import { ORDER_BOOKING, APP_ID } from '../../api/config'; -import { Card } from '../reusable/Card'; import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; -import { formatValue } from '../../lib/format'; -import { ShoppingCart, Store, User, ClipboardList, TrendingUp } from 'lucide-react'; -import { PotentialMiningTable, type MiningItem } from './PotentialMiningTable'; +import { + ShoppingCart, + Store, + User, + ClipboardList, + TrendingUp, + Edit3, + MapPin, + Clock, + CheckCircle2, + Phone, + Mail, + Truck, + FileText, + Camera, + Image as ImageIcon, + ArrowLeft, +} from 'lucide-react'; + export interface CallDetailProps { instanceId: number | string; selectedRow?: Record; potentialMiningAction?: ReactNode; placeOrderAction?: ReactNode; refreshKey?: number; + onBack?: () => void; } -/** Custom Call detail view matching specific groupings. */ -export function CallDetail({ instanceId, selectedRow, potentialMiningAction, placeOrderAction, refreshKey }: CallDetailProps) { +export function CallDetail({ + instanceId, + selectedRow, + potentialMiningAction, + placeOrderAction, + refreshKey, + onBack, +}: CallDetailProps) { const [data, setData] = useState | null>(null); + const [potentialData, setPotentialData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -30,9 +53,21 @@ export function CallDetail({ instanceId, selectedRow, potentialMiningAction, pla const r = await orderBookingClient.detailView(ORDER_BOOKING.detailViews.CALLS, instanceId); if (live) { setData(r.data || {}); + + const storeCode = (r.data?.select_store as any)?.store_code || (r.data?.select_store as any)?.code; + if (storeCode) { + orderBookingClient.request<{ potential: { potential: any[] } }>( + 'POST', + '/api/papi2/potential-mining', + { store_code: storeCode, instance_id: String(instanceId) }, + { 'TemplateID': '146' } + ).then(res => { + if (live) setPotentialData(res.potential?.potential || []); + }).catch(e => console.warn("Failed to fetch potential mining", e)); + } } } catch (e) { - if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); + if (live) setError((e as { message?: string })?.message ?? 'Failed to load details'); } finally { if (live) setLoading(false); } @@ -45,354 +80,876 @@ export function CallDetail({ instanceId, selectedRow, potentialMiningAction, pla if (error) { return ( - +
- - ); - } - - if (loading) { - return ( - -
- -
-
- ); - } - - if (!data || Object.keys(data).length === 0) { - return ( - - - - ); - } - - // Identify state - const rowSrc = selectedRow || data; - const stateName = String(rowSrc.current_state_name || rowSrc.current_state_name_ || rowSrc.current_state || rowSrc.status || 'Unknown'); - const valLower = stateName.toLowerCase(); - const isSuccess = valLower.includes('approve') || valLower.includes('complete') || valLower.includes('success') || valLower.includes('active') || valLower.includes('productive') || valLower.includes('ordered'); - const isWarning = valLower.includes('pending') || valLower.includes('draft') || valLower.includes('hold'); - const isDanger = valLower.includes('reject') || valLower.includes('fail') || valLower.includes('cancel'); - let colorClass = "text-blue-500"; - if (isSuccess) colorClass = "text-emerald-500"; - else if (isWarning) colorClass = "text-amber-500"; - else if (isDanger) colorClass = "text-red-500"; - - // Data Extraction and Fallback - const remainingData = { ...data }; - - const extract = (key: string, obj: any = data) => { - if (obj && key in obj) { - const val = obj[key]; - if (obj === remainingData) { - delete remainingData[key]; - } - return val; - } - return '-'; - }; - - const selectStore = (data.select_store as any) || {}; - if ('select_store' in remainingData) { - delete remainingData.select_store; - } - - // Also remove current_state to not render them again - delete remainingData.current_state_id; - delete remainingData.current_state_name; - - let orderIdVal = extract('order_id', remainingData); - let instanceIdVal = extract('instance_id', remainingData); - const orderId = orderIdVal !== '-' ? orderIdVal : instanceIdVal; - const orderDate = extract('date_of_order_3', remainingData); - const totalBags = extract('total_bags_3', remainingData); - const totalKgs = extract('total_kgs_3', remainingData); - const orderDetailsGrid = extract('order_details_3', remainingData); - - let potentialGrid: any[] = []; - if ('potential' in remainingData) { - const pot = remainingData.potential as any; - if (Array.isArray(pot)) { - potentialGrid = pot; - } else if (pot && typeof pot === 'object' && Array.isArray(pot.potential)) { - potentialGrid = pot.potential; - } - delete remainingData.potential; - } - - const miningData: MiningItem[] = potentialGrid.map((item: any, index: number) => { - let badgeType: 'error' | 'success' | 'info' | 'warning' | 'default' = 'default'; - const reason = item.reason || ''; - if (reason.toLowerCase().includes('quality')) badgeType = 'error'; - else if (reason.toLowerCase().includes('stock')) badgeType = 'success'; - else if (reason.toLowerCase().includes('price')) badgeType = 'warning'; - else if (reason.toLowerCase().includes('competitive')) badgeType = 'info'; - - return { - id: String(item.id || index), - productName: item.product_category || item.product_category_ || '-', - badge: reason ? { label: reason, type: badgeType } : undefined, - potentialKgs: Number(item.actual_potential || item.store_potential || 0), - orderedKgs: Number(item.total_ordered || 0), - }; - }); - - const storeName = selectStore.business_name || selectStore.store_name || '-'; - const storeLocation = selectStore.area || selectStore.location || '-'; - const dateOfVisit = extract('date_of_visit', remainingData); - - const customerId = selectStore.store_code || selectStore.customer_id || '-'; - const customerName = selectStore.owner_name || selectStore.customer_name || '-'; - - let phoneNumber = '-'; - if (selectStore.phone_number) { - phoneNumber = typeof selectStore.phone_number === 'object' - ? selectStore.phone_number.phone_with_dial_code || selectStore.phone_number.phone - : selectStore.phone_number; - } - - const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); - const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-'; - - const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')); - const userObj = userIdKey ? extract(userIdKey, remainingData) : null; - const userName = userObj && typeof userObj === 'object' ? userObj.name || userObj.email : '-'; - - const uploadImage = extract('upload_image', remainingData); - - const isVisitedState = valLower.includes('visited'); - const isProductiveCall = extract('is_productive_call', remainingData); - const actionTaken = extract('action', remainingData); - const orderChannel = extract('order_received_channel', remainingData); - const visitRemarks = extract('remarks', remainingData); - const storeStatus = extract('store_status', remainingData); - - // Helper for rendering a row - const Row = ({ label, value }: { label: string, value: any }) => { - if (value == null || value === '-' || value === '') return null; - return ( -
- {label}: - {formatValue(value)}
); + } + + if (loading) { + return ( +
+ +
+ ); + } + + const rowSrc = selectedRow || data || {}; + + // Extract store info with fallbacks matching reference + const selectStore = (rowSrc.select_store || data?.select_store || {}) as Record; + const storeName = selectStore.business_name || selectStore.store_name || rowSrc.store_name || 'No data'; + const storeCode = selectStore.store_code || selectStore.code || 'No data'; + const ownerName = selectStore.owner_name || selectStore.contact_person || 'No data'; + const phone = selectStore.phone_number?.phone || selectStore.phone_number?.phone_with_dial_code || selectStore.phone || 'No data'; + const email = selectStore.email || 'No data'; + const address = selectStore.complete_address || selectStore.address || 'No data'; + const route = selectStore.route_name ? `${selectStore.route_name} (${selectStore.route_code || 'No data'}) • ${selectStore.sub_route || 'No data'}` : 'No data'; + const areaLoc = selectStore.area || 'No data'; + + let lat = ''; + let lng = ''; + try { + const locStr = selectStore.store_location; + if (locStr) { + const loc = typeof locStr === 'string' ? JSON.parse(locStr) : locStr; + if (loc?.latitude && loc?.longitude) { + lat = loc.latitude; + lng = loc.longitude; + } + } + } catch (e) { } + + // Distributor info + const distCompany = selectStore.distributor_owner_name || 'No data'; + const distContact = selectStore.distributor_name || 'No data'; + const distPhone = selectStore.distributor_phone_number?.phone_with_dial_code || selectStore.distributor_phone_number?.phone || 'No data'; + const distEmail = selectStore.distributor_email || 'No data'; + + // Performed by info + const userObj = rowSrc['42b7f47d-96f0-4289-a6d9-38f455ad57dd__user_id'] || data?.['42b7f47d-96f0-4289-a6d9-38f455ad57dd__user_id'] as any; + const salesOfficer = userObj?.name || String(rowSrc.created_by || data?.created_by || 'No data'); + const officerEmail = userObj?.email || 'No data'; + const officerJob = userObj?.job_title || 'Sales Officer'; + + // Extract activity timestamps + const formatTimeOnly = (isoStr?: string, fallback = 'No data') => { + if (!isoStr) return fallback; + try { + const d = new Date(isoStr); + if (isNaN(d.getTime())) return fallback; + const hours = String(d.getHours()).padStart(2, '0'); + const mins = String(d.getMinutes()).padStart(2, '0'); + return `${hours}:${mins}`; + } catch { + return fallback; + } }; + const formatDateOnly = (isoStr?: string, fallback = '') => { + if (!isoStr) return fallback; + try { + const d = new Date(isoStr); + if (isNaN(d.getTime())) return fallback; + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = String(d.getFullYear()).slice(2); + return `${day}/${month}/${year}`; + } catch { + return fallback; + } + }; + + const formatFullDateTime = (isoStr?: string, fallback = 'No data') => { + if (!isoStr) return fallback; + try { + const d = new Date(isoStr); + if (isNaN(d.getTime())) return fallback; + const day = d.getDate(); + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = monthNames[d.getMonth()]; + const year = d.getFullYear(); + let hours = d.getHours(); + const minutes = String(d.getMinutes()).padStart(2, '0'); + const ampm = hours >= 12 ? 'pm' : 'am'; + hours = hours % 12; + hours = hours ? hours : 12; + return `${day} ${month} ${year}, ${String(hours).padStart(2, '0')}:${minutes} ${ampm}`; + } catch { + return fallback; + } + }; + + // Activity 1: Log Visit timestamp (42b7f47d-96f0-4289-a6d9-38f455ad57dd__created_at) + const rawLogVisitTs = rowSrc['42b7f47d-96f0-4289-a6d9-38f455ad57dd__created_at'] ?? data?.['42b7f47d-96f0-4289-a6d9-38f455ad57dd__created_at']; + // Activity 2: Productivity of Visit timestamp (11bd10f9-a001-470e-867f-33dee8eabe4b__created_at) + const rawProductivityTs = rowSrc['11bd10f9-a001-470e-867f-33dee8eabe4b__created_at'] ?? data?.['11bd10f9-a001-470e-867f-33dee8eabe4b__created_at']; + // Activity 3: Potential Mining timestamp (af8ac8df-d868-4f7d-88b2-123955d69c56__created_at) + const rawPotentialMiningTs = rowSrc['af8ac8df-d868-4f7d-88b2-123955d69c56__created_at'] ?? data?.['af8ac8df-d868-4f7d-88b2-123955d69c56__created_at']; + // Activity 4: Place Order timestamp (f66403d7-e31f-4d64-bcfd-ff5485aec8b8__created_at) + const rawPlaceOrderTs = rowSrc['f66403d7-e31f-4d64-bcfd-ff5485aec8b8__created_at'] ?? data?.['f66403d7-e31f-4d64-bcfd-ff5485aec8b8__created_at']; + + const isLogVisitDone = rawLogVisitTs != null && String(rawLogVisitTs).trim() !== ''; + const isProductivityDone = rawProductivityTs != null && String(rawProductivityTs).trim() !== ''; + const isPotentialMiningDone = rawPotentialMiningTs != null && String(rawPotentialMiningTs).trim() !== ''; + const isPlaceOrderDone = rawPlaceOrderTs != null && String(rawPlaceOrderTs).trim() !== ''; + + const time1 = isLogVisitDone ? formatTimeOnly(String(rawLogVisitTs)) : null; + const time2 = isProductivityDone ? formatTimeOnly(String(rawProductivityTs)) : null; + const timePm = isPotentialMiningDone ? formatTimeOnly(String(rawPotentialMiningTs)) : null; + const time3 = isPlaceOrderDone ? formatTimeOnly(String(rawPlaceOrderTs)) : null; + + const date1 = isLogVisitDone ? formatDateOnly(String(rawLogVisitTs)) : null; + const date2 = isProductivityDone ? formatDateOnly(String(rawProductivityTs)) : null; + const datePm = isPotentialMiningDone ? formatDateOnly(String(rawPotentialMiningTs)) : null; + const date3 = isPlaceOrderDone ? formatDateOnly(String(rawPlaceOrderTs)) : null; + + const fullDateTimeOrder = formatFullDateTime(String(rawPlaceOrderTs || rawLogVisitTs || '')); + + // Call Potential items (below orders) + let callPotentialList: any[] = []; + const parseCallPotentialItem = (p: any) => { + const name = p.product_category_ || p.product_category || p.name || p.category || 'No data'; + const actualPotential = Number(p.actual_potential || p.quantity || p.store_potential || 0); + const totalOrdered = Number(p.total_ordered || 0); + const difference = Number(p.difference || 0); + const reason = p.reason || null; + return { + name, + actualPotential, + totalOrdered, + difference, + reason, + kgs: `${actualPotential} kgs`, + val: actualPotential, + maxVal: Math.max(actualPotential, totalOrdered, 400), + raw: p, + }; + }; + + const savedPotentialStr = rowSrc.potential || data?.potential; + let savedPotential: any[] = []; + if (Array.isArray(savedPotentialStr)) { + savedPotential = savedPotentialStr; + } else if (typeof savedPotentialStr === 'string') { + try { + savedPotential = JSON.parse(savedPotentialStr); + } catch (e) { } + } + + let mergedCallPot = potentialData ? [...potentialData] : [...savedPotential]; + + if (potentialData && potentialData.length > 0 && savedPotential.length > 0) { + mergedCallPot = potentialData.map((pd: any) => { + const pdCat = String(pd.product_category_ || pd.product_category || pd.name || pd.category).toLowerCase().trim(); + const match = savedPotential.find((sp: any) => { + const spCat = String(sp.product_category_ || sp.product_category || sp.name || sp.category).toLowerCase().trim(); + return spCat === pdCat; + }); + return { ...pd, reason: match?.reason || pd.reason }; + }); + + savedPotential.forEach(sp => { + const spCat = String(sp.product_category_ || sp.product_category || sp.name || sp.category).toLowerCase().trim(); + const exists = mergedCallPot.some((mp: any) => { + const mpCat = String(mp.product_category_ || mp.product_category || mp.name || mp.category).toLowerCase().trim(); + return mpCat === spCat; + }); + if (!exists) mergedCallPot.push(sp); + }); + } + + if (Array.isArray(mergedCallPot) && mergedCallPot.length > 0) { + callPotentialList = mergedCallPot.map(parseCallPotentialItem); + } + + // Store Potential items (below store details) + let storePotentialList: any[] = []; + const rawStorePot = selectStore.potential; + const parseStorePotentialItem = (p: any) => { + const name = p.product_category_ || p.product_category || p.name || p.category || 'No data'; + const actualPotential = Number(p.actual_potential || p.quantity || p.store_potential || 0); + return { + name, + actualPotential, + }; + }; + + if (typeof rawStorePot === 'string') { + try { + const parsed = JSON.parse(rawStorePot); + if (Array.isArray(parsed)) { + storePotentialList = parsed.map(parseStorePotentialItem); + } + } catch (e) { + console.error("Failed to parse store potential JSON:", e); + } + } else if (Array.isArray(rawStorePot) && rawStorePot.length > 0) { + storePotentialList = rawStorePot.map(parseStorePotentialItem); + } + + // Extract order line items + let orderItems: any[] = []; + const rawOrderItems = rowSrc.order_details || data?.order_details || rowSrc.order_details_3 || data?.order_details_3; + if (Array.isArray(rawOrderItems) && rawOrderItems.length > 0) { + orderItems = rawOrderItems.map((item: any) => { + const bags = Number(item.bags || item.bags_3 || item.quantity || 0); + const skuVal = Number(item.sku || item.sku_3 || '30'); + return { + name: item.product_name || item.product_name_3 || item.product_category || 'MAIDA PUFF 30 kgs', + category: item.product_category || item.product_category_3 || item.category || 'MAIDA PUFF', + sku: item.sku || item.sku_3 || '30', + skuCode: item.sku_code || item.sku_code_3 || 'MP30', + brCode: item.br_code || item.br_code_3 || item.br || 'PM', + bags, + totalKgs: bags * skuVal, + }; + }); + } else { + orderItems = []; + } + + // Calculate totals + const totalBags = Number(rowSrc.total_bags_3 || rowSrc.total_bags || data?.total_bags_3 || data?.total_bags || orderItems.reduce((acc, i) => acc + i.bags, 0)); + const totalKgs = Number(rowSrc.total_kgs_3 || rowSrc.total_kgs || data?.total_kgs_3 || data?.total_kgs || orderItems.reduce((acc, i) => acc + (i.bags * (Number(i.sku) || 1)), 0)); + const lineItemsCount = orderItems.length; + + const orderId = String(rowSrc.order_id || data?.order_id || rowSrc.order_number || `No data`); + const dateOfOrder = String(rowSrc.date_of_order_3 || data?.date_of_order_3 || rowSrc.date_of_order || 'No data'); + + // Extract uploaded proof photos from backend API + const uploadedImages = (() => { + const imgData = rowSrc.upload_image || data?.upload_image || selectStore.upload_image; + if (Array.isArray(imgData)) return imgData; + if (imgData && typeof imgData === 'object' && (imgData as any).uuid) return [imgData]; + return []; + })(); + + // Extract store banner photo from backend API + const storeImages = (() => { + const rawImg = selectStore.store_image || selectStore.upload_image || rowSrc.store_image || data?.store_image; + if (typeof rawImg === 'string') { + try { + const parsed = JSON.parse(rawImg); + if (Array.isArray(parsed)) return parsed; + } catch (e) { + console.error("Failed to parse store_image JSON:", e); + } + } + if (Array.isArray(rawImg)) return rawImg; + if (rawImg && typeof rawImg === 'object' && (rawImg as any).uuid) return [rawImg]; + return []; + })(); + + // Visit log details + const dateOfVisit = String(rowSrc.date_of_visit || data?.date_of_visit || 'No data'); + const timeOfVisit = String(rowSrc.time_of_visit || data?.time_of_visit || 'No data'); + const storeStatus = String(rowSrc.store_status || data?.store_status || 'No data'); + const channel = String(rowSrc.order_received_channel + || rowSrc.channel || data?.order_received_channel || 'No data'); + const geoLocation = String(selectStore.store_location ? (typeof selectStore.store_location === 'string' ? selectStore.store_location : `${selectStore.store_location.latitude}, ${selectStore.store_location.longitude}`) : 'No data'); + const storeNotes = selectStore.notes || 'No notes available.'; + const currentStateName = String(rowSrc.current_state_name || data?.current_state_name || 'No data'); + return ( -
- - {/* 1. Status Card */} - -
-
-
Status
-
- {stateName} +
+ + {/* 1. Primary Summary & Status Banner */} +
+
+ + {/* Title & Badges */} +
+ {onBack && ( + + )} +
+
+ {/* {isPlaceOrderDone && ( + + Ordered + + )} */} + {currentStateName !== 'No data' && ( + + {currentStateName} + + )} + {/* + Productive call + + + On call + */} + {/* + {isPlaceOrderDone ? `Order ${orderId}` : 'Order Not Placed'} + */} +
+ +

+ {storeName} +

+ +
+ + {areaLoc} + + + {route} + + + {fullDateTimeOrder} + +
- {placeOrderAction && ( -
{placeOrderAction}
- )} -
- - {/* 2. Order Details Card */} - -
- -

Order Details

-
-
-
- - - - + {/* Top Right KPI Grid */} +
+
+
TOTAL BAGS
+
{totalBags.toLocaleString()}
+
+
+
TOTAL KGS
+
{totalKgs.toLocaleString()}
+
+
+
LINE ITEMS
+
{lineItemsCount}
+
+
+
SALES OFFICER
+
{salesOfficer}
+
+
- {Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && ( -
-

Line Items

-
- - - - - - - - - - - - {orderDetailsGrid.map((item: any, idx: number) => { - const skuCode = item.sku_code_3 || '-'; - let chipColor = 'bg-slate-50 text-slate-700 border-slate-200'; - if (skuCode !== '-') { - const hash = skuCode.split('').reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0); - const colors = [ - 'bg-blue-50 text-blue-700 border-blue-200', - 'bg-emerald-50 text-emerald-700 border-emerald-200', - 'bg-amber-50 text-amber-700 border-amber-200', - 'bg-rose-50 text-rose-700 border-rose-200', - 'bg-purple-50 text-purple-700 border-purple-200', - 'bg-indigo-50 text-indigo-700 border-indigo-200', - 'bg-pink-50 text-pink-700 border-pink-200', - 'bg-cyan-50 text-cyan-700 border-cyan-200' - ]; - chipColor = colors[hash % colors.length]; - } + {/* Workflow Progress Stepper - 3 Timestamps */} +
+
- return ( -
- - - - - + {/* Step 1: Log Visit */} +
+ {isLogVisitDone ? ( +
+ ✓ +
+ ) : ( +
+ +
+ )} +
+
Visit logged
+
+ {isLogVisitDone ? `${time1} • ${date1}` : 'Not performed'} +
+
+
+ + {/* Step 2: Productivity of Visit */} +
+ {isProductivityDone ? ( +
+ ✓ +
+ ) : ( +
+ +
+ )} +
+
Productive call
+
+ {isProductivityDone ? `${time2} • ${date2}` : 'Not performed'} +
+
+
+ + {/* Step 3: Potential Mining */} +
+ {isPotentialMiningDone ? ( +
+ ✓ +
+ ) : ( +
+ +
+ )} +
+
Potential Mining
+
+ {isPotentialMiningDone ? `${timePm} • ${datePm}` : 'Not performed'} +
+
+
+ + {/* Step 4: Place Order */} +
+ {isPlaceOrderDone ? ( +
+ ✓ +
+ ) : ( +
+ +
+ )} +
+
Ordered
+
+ {isPlaceOrderDone ? `${time3} • ${date3}` : 'Not performed'} +
+
+
+ + + + + + {/* 2. Main Two-Column Layout */} +
+ + {/* MAIN CONTENT (Now on Right, 2/3 width) */} +
+ + {/* Order Details Card */} + {currentStateName.toLowerCase() !== 'no order' && ( +
+
+
+ +

Order Details

+
+
+ + {isPlaceOrderDone ? `${orderId} • ${dateOfOrder}` : 'Not Performed'} + + {placeOrderAction ? ( +
{placeOrderAction}
+ ) : ( + + )} +
+
+ + {/* Table */} +
+ {orderItems.length > 0 ? ( +
Product NameSKUSKU CodeBR CodeBags
{item.product_name_3 || item.product_category_3 || '-'}{item.sku_3 || '-'} - - {skuCode} - - {item.br_code_3 || '-'}{item.bags_3 || '-'}
+ + + + + + + + + + + + {orderItems.map((item, idx) => { + let codeBadgeClass = "bg-blue-50 text-blue-700 border-blue-200"; + if (item.skuCode?.includes('50')) codeBadgeClass = "bg-indigo-50 text-indigo-700 border-indigo-200"; + if (item.skuCode?.includes('MAP')) codeBadgeClass = "bg-emerald-50 text-emerald-700 border-emerald-200"; + + return ( + + + + + + + + + ); + })} + + + + + + + + +
PRODUCTSKUSKU CODEBR CODEBAGSTOTAL KGS
+
{item.name}
+
{item.category}
+
{item.sku} + + {item.skuCode} + + {item.brCode}{item.bags}{item.totalKgs.toLocaleString()}
Total{totalBags}{totalKgs.toLocaleString()}
+ ) : ( +
+ + Order not placed +
+ )} +
+ + {/* Total Summary Row (Moved to table footer) */} + + {/* Meta Footer */} + {isPlaceOrderDone && ( +
+
+ CREATED BY {salesOfficer} • {officerJob || 'Sales Officer'} +
+
+ CREATED AT {fullDateTimeOrder} +
+
+ )} +
+ )} + + {/* Call Potential Card (Below Orders) */} + {callPotentialList.length > 0 && ( +
+
+
+ +

Potential vs Ordered

+
+ Ordered vs opportunity +
+ + {(() => { + const regularPotential = callPotentialList.filter(item => item.actualPotential > 0); + const extraOrdered = callPotentialList.filter(item => item.actualPotential === 0); + + const renderTable = (items: any[]) => ( +
+ + + + + + + + + + + {items.map((item, idx) => ( + + + + + + + + ))} + +
PRODUCTPOTENTIAL (KG)ORDERED (KG)DIFFERENCEREASON
+
{item.name}
+
{item.actualPotential}{item.totalOrdered} + + {item.difference > 0 ? '+' : ''}{item.difference} + + + {item.reason && ( + + {item.reason} + + )} +
+
+ ); + + return ( +
+ {regularPotential.length > 0 && renderTable(regularPotential)} + + {extraOrdered.length > 0 && ( +
+
+ +

Ordered Outside Potential

+
+ {renderTable(extraOrdered)} +
+ )} +
+ ); + })()} + + {potentialMiningAction && ( +
+ {potentialMiningAction} +
+ )} +
+ )} + + + + {/* Visit Log Card */} +
+
+
+ +

Visit Log

+
+ Logged at {time1} +
+ +
+ {/* Left Key Value Details */} +
+
+ DATE OF VISIT + {dateOfVisit} +
+
+ TIME OF VISIT + {timeOfVisit} +
+
+ STORE STATUS + {storeStatus} +
+
+ CHANNEL + {channel} +
+
+ + {/* Right Store Proof Photo */} +
+
+ UPLOADED PROOF +
+ + {uploadedImages.length > 0 ? ( +
+ {uploadedImages.map((file: any, idx: number) => { + const previewUrl = `${orderBookingClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; + return ( +
+ {file.original_name { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).parentElement!.innerHTML = `
${file.original_name || 'Proof Image'}
`; + }} + /> + +
); })} +
+ ) : ( +
+ + No proof image uploaded +
+ )} +
+
+
+ +
+ + {/* SIDEBAR (Now on Left, 1/3 width) */} +
+ + {/* Store Banner Image Card */} +
+ {storeImages.length > 0 ? ( +
+ Store Front { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ ) : ( +
+
+ +
+ {storeName} +
+ )} +
+ + {/* Store Info Card */} +
+ + {/* Store Potential Card */} + {storePotentialList.length > 0 && ( +
+
+
+ +

Store Potential

+
+ Total capacity +
+ +
+ + + + + + + + + {storePotentialList.map((item, idx) => ( + + + + + ))}
PRODUCTPOTENTIAL (KG)
+
{item.name}
+
{item.actualPotential}
)} - {potentialMiningAction && ( -
- {potentialMiningAction} + {/* Distributor Card */} +
+
+ +

Distributor

- )} -
- - -
-
- - - {/* 3. Store Details Card */} - -
- -

Store Details

-
-
- - - -
-
- - {/* 4. Customer Details Card */} - -
- -

Customer Details

-
-
- - - -
-
- - {/* Productivity of Visit Card */} - {isVisitedState && ( - -
- -

Productivity of Visit

-
-
- - - - - -
-
- )} - - {/* Potential Mining Card */} - {miningData.length > 0 && ( - -
- -

Potential Mining

-
-
- -
-
- )} - - {/* 6. Log Visit Details Fallback */} - {Object.keys(remainingData).length > 0 && ( - -
- -

Log Visit Details

-
-
- {Object.entries(remainingData).map(([key, value]) => { - const lowerKey = key.toLowerCase(); - if (lowerKey.includes('uuid') || /^\d+$/.test(key)) return null; - // we just render them all as rows for simplicity in this custom view - let finalValue = value; - if (finalValue && typeof finalValue === 'object') { - try { - finalValue = JSON.stringify(finalValue); - } catch (e) { - finalValue = '-'; - } - } - // format label to be readable - 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 ; - })} - - {Array.isArray(uploadImage) && uploadImage.length > 0 && ( -
- Uploaded Image: -
- {uploadImage.map((file: any, idx: number) => { - const previewUrl = `${orderBookingClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; - return ( -
- {file.original_name { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).parentElement!.innerHTML = `${file.original_name || 'File'}`; - }} - /> - -
- ); - })} -
+
+
+ COMPANY + {distCompany}
- )} +
+ CONTACT + {distContact} +
+
+ PHONE + + {distPhone} + +
+
+ EMAIL + + {distEmail} + +
+
- - )} + + {/* Performed By Card */} +
+
+ +

Performed by

+
+ +
+
+ NAME + {salesOfficer} +
+
+ ROLE + {officerJob || 'Sales Officer'} +
+
+ EMAIL + + {officerEmail} + +
+
+
+ +
+ +
); diff --git a/src/components/dv/DailyLogDetail.tsx b/src/components/dv/DailyLogDetail.tsx index 0702d0b..54fb45a 100644 --- a/src/components/dv/DailyLogDetail.tsx +++ b/src/components/dv/DailyLogDetail.tsx @@ -4,239 +4,382 @@ import { DAILY_REPORTS, APP_ID } from '../../api/config'; import { Card } from '../reusable/Card'; import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; -import { formatValue } from '../../lib/format'; +import { Calendar, Clock, Map, User, ClipboardList, Camera, MapPin, Hash, CheckCircle, XCircle, Activity, ArrowLeft, Mail } from 'lucide-react'; import { Button } from '../buttons/Button'; -import { Map, User, ClipboardList, Camera, Clock } from 'lucide-react'; export interface DailyLogDetailProps { - instanceId: number | string; - onPunchOut?: () => void; + instanceId: number | string; + refreshKey?: number; + onPunchOut?: () => void; + onBack?: () => void; } -export function DailyLogDetail({ instanceId, onPunchOut }: DailyLogDetailProps) { - const [data, setData] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +export function DailyLogDetail({ instanceId, refreshKey, onPunchOut, onBack }: DailyLogDetailProps) { + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { - let live = true; - async function run() { - setLoading(true); - setError(null); - try { - const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId); - if (live) setData(r.data || {}); - } catch (e) { - if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); - } finally { - if (live) setLoading(false); + useEffect(() => { + let live = true; + async function run() { + setLoading(true); + setError(null); + try { + const r = await dailyReportsClient.detailView(DAILY_REPORTS.detailViews.DAILY_LOGS, instanceId); + if (live) setData(r.data || {}); + } catch (e) { + if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); + } finally { + if (live) setLoading(false); + } } - } - run(); - return () => { live = false; }; - }, [instanceId]); + run(); + return () => { live = false; }; + }, [instanceId, refreshKey]); - if (error) { - return ( - - - - ); - } - - if (loading) { - return ( - -
-
- ); - } - - if (!data || Object.keys(data).length === 0) { - return ( - - - - ); - } + if (error) { + return ( + + + + ); + } - const remainingData = { ...data }; - const extract = (key: string, obj: any = data) => { - if (obj && key in obj) { - const val = obj[key]; - delete obj[key]; - return val; - } - return '-'; - }; + if (loading) { + return ( + +
+
+ ); + } - // State - const stateName = extract('current_state_name', remainingData) as string; - delete remainingData.current_state_id; - - 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'); + if (!data || Object.keys(data).length === 0) { + return ( + + + + ); + } - // General Info - const date = extract('date', remainingData); - const time = extract('time', remainingData); - const routeCode = extract('route_code', remainingData); - const subRoute = extract('sub_route', remainingData); - const dayPlanNotes = extract('day_plan_notes', remainingData); + const remainingData = { ...data }; + const extract = (key: string, obj: any = data) => { + if (obj && key in obj) { + const val = obj[key]; + return val; + } + return null; + }; - // Officer Info - const officerNameRaw = extract('sales_officer_name', remainingData); - let officerName = '-'; - let officerEmail = '-'; - if (officerNameRaw && typeof officerNameRaw === 'object') { - officerName = (officerNameRaw as any).name || '-'; - officerEmail = (officerNameRaw as any).email || '-'; - } + // State + const stateName = extract('current_state_name', remainingData) as string || '-'; + const isPunchedIn = stateName.toLowerCase().includes('punched in'); - // Image - const storeImage = extract('store_image', remainingData); + // Date and Time + let dateRaw = extract('date', remainingData); + let timeRaw = extract('time', remainingData); + let formattedDate = '-'; + if (dateRaw) { + formattedDate = new Date(dateRaw as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + } - // EOD Info - const totalProductive = extract('total_productive_calls', remainingData); - const totalNonProductive = extract('total_non_productive_calls', remainingData); - const eodNotes = extract('eod_notes_remarks', remainingData); + // Format punch in text + const punchInTimeText = timeRaw ? `Punch-in ${timeRaw}` : 'Punch-in time unknown'; - // Meta - const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); - const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-'; + // Route Info + const routeCode = extract('route_code', remainingData) || '-'; + const subRoute = extract('sub_route', 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 ( -
- {label}: - {formatValue(finalValue)} -
- ); - }; + // Notes & Details + const dayPlanNotes = extract('day_plan_notes', remainingData) || '-'; + const eodNotes = extract('eod_notes_remarks', remainingData) || '-'; - return ( -
- - {/* 1. Status Card */} - -
-
-
Current State
-
- {stateName} -
-
- {isPunchedIn && onPunchOut && ( - - )} -
-
+ // 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; - {/* 2. Route & Plan Card */} - -
- -

Day Plan & Route

-
-
- - - - - -
-
+ // 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'; - {/* 3. Officer Info */} - -
- -

Sales Officer

-
-
- - -
-
+ // 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'); + } - {/* 4. EOD Details (Only if not punched in) */} - {!isPunchedIn && ( - -
- -

End of Day Details

-
-
- - - -
-
- )} + // Image + const storeImage = extract('store_image', remainingData); + const imageFiles = Array.isArray(storeImage) ? storeImage : []; - {/* 5. Image Card */} - {Array.isArray(storeImage) && storeImage.length > 0 && ( - -
- -

Store Image

-
-
-
- {storeImage.map((file: any, idx: number) => { - const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; - return ( -
- {file.original_name { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).parentElement!.innerHTML = `${file.original_name || 'File'}`; - }} - /> - + return ( +
+ + {/* Top Header Card */} +
+
+
+ {onBack && ( + + )} +
+
Day Plan
+
{formattedDate}
+
+ {punchInTimeText} + Route {String(routeCode)} - {String(subRoute)} +
+
+
+ +
+
+
+ {soInitials} +
+
+
{String(soName)}
+
Sales Officer - ID {String(soId)}
+
+
+ {isPunchedIn && onPunchOut && ( + + )} +
- ); - })} -
-
- - )} - {/* 6. Other Details */} - {(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0) && ( - -
- -

Other Details

-
-
- - {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 ; - })} -
-
- )} + {/* KPI Row & Progress Bar (Only show if Punched Out) */} + {!isPunchedIn && ( + <> +
+
+
+ Productive Calls +
+
{String(prodCalls)}
+
+
+
+ Non Productive +
+
{String(nonProdCalls)}
+
+
+
+ Total Calls +
+
{totalCalls}
+
+
+
+ Productivity +
+
{productivity}%
+
+
-
- ); +
+ Productive vs Non Productive + {String(prodCalls)} / {totalCalls} +
+
+ {totalCalls > 0 && ( + <> +
+
+ + )} +
+ + )} +
+ + {/* Split Content */} +
+ + {/* Left Column */} +
+ {/* Timeline */} +
+
+ Day Timeline +
+ +
+ {/* Punched In */} +
+
+
+ +
+
+
Punched In
+
{formattedDate} {timeRaw ? `- ${timeRaw}` : ''}
+
+ + {/* Day Plan */} +
+
+
+
+
Day Plan Recorded
+
{String(dayPlanNotes)}
+
+ + {/* EOD Submitted */} +
+
+
+ +
+
+
EOD Submitted
+ {!isPunchedIn && ( +
{createdAt}
+ )} +
+
+
+ + {/* Notes Row */} +
+
+
+ Day Plan Notes +
+
{String(dayPlanNotes)}
+
+ {!isPunchedIn && ( +
+
+ EOD Remarks +
+
{String(eodNotes)}
+
+ )} +
+ + {/* Route Assignment */} +
+
+ Route Assignment +
+
+
+
Route Code
+
{String(routeCode)}
+
+
+
Sub Route
+
{String(subRoute)}
+
+
+
+
+ + {/* Right Column */} +
+ {/* Store Image */} +
+
+ Store Image +
+
+ {imageFiles.length > 0 ? imageFiles.map((file: any, idx: number) => { + const previewUrl = `${dailyReportsClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; + return ( +
+ Store +
+ ); + }) : ( +
+ + No image uploaded +
+ )} +
Uploaded at punch in.
+
+
+ + {/* SO Card */} +
+
+ Sales Officer +
+ +
+
+
+ +
+
+
Name
+
{String(soName)}
+
+
+
+
+ +
+
+
Email
+
{String(soEmail)}
+
+
+
+
+ +
+
+
User ID
+
{String(soId)}
+
+
+
+
+ + {/* Meta Card */} +
+
+ Meta +
+
+
+ Instance + #{instanceId} +
+
+ State + {stateName} +
+
+ Performed At + {createdAt} +
+
+
+
+
+
+ ); } diff --git a/src/components/dv/OrderDetail.tsx b/src/components/dv/OrderDetail.tsx index f44510e..a747b14 100644 --- a/src/components/dv/OrderDetail.tsx +++ b/src/components/dv/OrderDetail.tsx @@ -4,9 +4,7 @@ import { ORDER_BOOKING } from '../../api/config'; import { Card } from '../reusable/Card'; import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; -import { formatValue } from '../../lib/format'; -import { ShoppingCart, Store, User, Package, Clock, ClipboardList } from 'lucide-react'; -import { GridTable } from './GridTable'; +import { Store, User, Package, Calendar, Hash, Mail, FileText, Weight, List, MapPin } from 'lucide-react'; export interface WiredDetailViewProps { instanceId: number | string; @@ -38,23 +36,23 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) { if (error) { return ( - - + + ); } if (loading) { return ( - -
+ +
); } if (!data || Object.keys(data).length === 0) { return ( - + ); @@ -67,28 +65,34 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) { delete obj[key]; return val; } - return '-'; + return null; }; // State - const stateName = extract('current_state_name', remainingData); + const stateName = extract('current_state_name', remainingData) || 'Ordered'; delete remainingData.current_state_id; - const isSuccess = ['ordered', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s)); - const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500'; // Order Details - const orderId = extract('order_id', remainingData); - const dateOfOrder = extract('date_of_order_3', remainingData); + const orderId = extract('order_id', remainingData) || '-'; + let dateOfOrder = extract('date_of_order_3', remainingData); + if (dateOfOrder) { + dateOfOrder = new Date(dateOfOrder as string).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + } else { + dateOfOrder = '-'; + } // SO Info const soKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')) || 'so_name'; const soRaw = extract(soKey, remainingData); let soName = '-'; let soEmail = '-'; + let soId = '-'; if (soRaw && typeof soRaw === 'object') { soName = (soRaw as any).name || '-'; soEmail = (soRaw as any).email || '-'; + soId = (soRaw as any).user_id || (soRaw as any).id || (soRaw as any).uid || '-'; } + const soInitials = soName.length > 2 ? soName.substring(0,2).toUpperCase() : 'SO'; // Store Info const storeRaw = extract('select_store', remainingData); @@ -105,144 +109,171 @@ export function OrderDetail({ instanceId }: WiredDetailViewProps) { // Line Items const orderDetailsGrid = extract('order_details_3', remainingData); + const itemsArray = Array.isArray(orderDetailsGrid) ? orderDetailsGrid : []; // Totals - const totalBags = extract('total_bags_3', remainingData); - const totalKgs = extract('total_kgs_3', remainingData); - - // Meta - const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); - const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-'; - - // Helper Row - const Row = ({ label, value, highlight = false }: { label: string, value: any, highlight?: boolean }) => { - let finalValue = value; - if (finalValue && typeof finalValue === 'object') { - try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; } - } - return ( -
- {label}: - - {formatValue(finalValue)} - -
- ); - }; + const totalBags = extract('total_bags_3', remainingData) || 0; + const totalKgs = extract('total_kgs_3', remainingData) || 0; return ( -
- - {/* 1. Status Card */} - -
-
-
Status
-
- {stateName} +
+ {/* Top Section */} +
+
+
Order ID
+
{String(orderId)}
+
+ {String(dateOfOrder)} + Instance #{instanceId} +
+
+
+
+ Store +
+
{String(storeName)}
+
+ {String(routeCode)} - {String(routeName)} +
+
+ {String(distributorName)} +
+
+
+ + {/* Middle Stats Section */} +
+
+
+ Total Bags +
+
{String(totalBags)}
+
+
+
+ Total Kilograms +
+
{Number(totalKgs).toLocaleString()}
+
+
+
+ Line Items +
+
{itemsArray.length}
+
+
+ + {/* Bottom Section */} +
+ {/* Left Table */} +
+
+ + Order Items
-
-
- +
+ + + + + + + + + + + {itemsArray.map((item: any, idx: number) => { + const product = item.product_name_3 || '-'; + const category = item.product_category_3 || '-'; + const sku = item.sku_code_3 || '-'; + const bags = item.bags_3 || '0'; + const brCode = item.br_code_3 || '-'; + const weight = item.sku_3 ? `${item.sku_3} kg` : '-'; - {/* 2. Order Summary Card */} - -
- -

Order Summary

-
-
- - -
-
+ return ( + + + + + + + ); + })} + + + + + +
ProductCategorySKUBags
+
{String(product)}
+
BR Code {String(brCode)}
+
{String(category)} +
{String(sku)}
+
{String(weight)}
+
{String(bags)}
Total{String(totalBags)}
+
+
- {/* 3. Store details Card */} - -
- -

Store Details

-
-
- - - -
-
- - {/* 4. Sales Officer Card */} - -
- -

Sales Officer

-
-
- - -
-
- - {/* 5. Line Items Grid */} - {Array.isArray(orderDetailsGrid) && orderDetailsGrid.length > 0 && ( - -
- -

Line Items

-
-
- -
-
- )} - - {/* 6. Order Totals */} - -
- -

Order Totals

-
-
- - -
-
- - {/* 7. Log Visit Details Fallback & Meta */} - {(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k) && k !== 'instance_id').length > 0 || createdAt !== '-') && ( - -
- -

Other Details

-
-
- - {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 ; - })} -
-
- )} + {/* Right Sidebar */} +
+ {/* SO Card */} +
+
+ Sales Officer +
+
+
+ {soInitials} +
+
+
{String(soName)}
+
Sales Officer - ID {String(soId)}
+
+
+
+
+
+ +
+
+
Email
+
{String(soEmail)}
+
+
+
+
+ +
+
+
User ID
+
{String(soId)}
+
+
+
+
+ {/* Order Meta */} +
+
+ Order Meta +
+
+
+ Instance + #{instanceId} +
+
+ State + {String(stateName)} +
+
+ Date of Order + {String(dateOfOrder)} +
+
+
+
+
); } diff --git a/src/components/dv/StoreDetail.tsx b/src/components/dv/StoreDetail.tsx index 924c41c..e4a5e9c 100644 --- a/src/components/dv/StoreDetail.tsx +++ b/src/components/dv/StoreDetail.tsx @@ -4,316 +4,463 @@ import { STORE, APP_ID } from '../../api/config'; import { Card } from '../reusable/Card'; import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; -import { formatValue } from '../../lib/format'; -import { Store, User, Truck, MapPin, TrendingUp, Camera, ClipboardList } from 'lucide-react'; -import { GridTable } from './GridTable'; -import type { WiredDetailViewProps } from './OrderDetail'; +import { ArrowLeft, Store, User, Truck, MapPin, TrendingUp, Phone, Navigation2, Edit3, Navigation, Mail, FileText, Hash } from 'lucide-react'; +import { Button } from '../buttons/Button'; -export function StoreDetail({ instanceId }: WiredDetailViewProps) { - const [data, setData] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let live = true; - async function run() { - setLoading(true); - setError(null); - try { - const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId); - if (live) setData(r.data || {}); - } catch (e) { - if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); - } finally { - if (live) setLoading(false); - } - } - run(); - return () => { live = false; }; - }, [instanceId]); - - if (error) { - return ( - - - - ); - } - - if (loading) { - return ( - -
-
- ); - } - - if (!data || Object.keys(data).length === 0) { - return ( - - - - ); - } - - const remainingData = { ...data }; - const extract = (key: string, obj: any = data) => { - if (obj && key in obj) { - const val = obj[key]; - delete obj[key]; - return val; - } - return '-'; - }; - - // State - const stateName = extract('current_state_name', remainingData); - delete remainingData.current_state_id; - const isSuccess = ['created', 'active', 'approve'].some(s => stateName.toString().toLowerCase().includes(s)); - const colorClass = isSuccess ? 'text-emerald-500' : 'text-blue-500'; - - // Store Overview - const storeCode = extract('store_code', remainingData); - const businessName = extract('business_name', remainingData); - const area = extract('area', remainingData); - const completeAddress = extract('complete_address', remainingData); - const pinCode = extract('pin_code', remainingData); - const notes = extract('notes', remainingData); - - // Owner Info - const ownerName = extract('owner_name', remainingData); - const email = extract('email', remainingData); - let phoneNumber = extract('phone_number', remainingData); - if (typeof phoneNumber === 'object' && phoneNumber !== null) { - phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-'; - } - - // Distributor Info - const distributorName = extract('distributor_name', remainingData); - const distributorOwnerName = extract('distributor_owner_name', remainingData); - const distributorEmail = extract('distributor_email', remainingData); - let distributorPhone = extract('distributor_phone_number', remainingData); - if (typeof distributorPhone === 'object' && distributorPhone !== null) { - distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-'; - } - - // Route Info - const routeCode = extract('route_code', remainingData); - const routeName = extract('route_name', remainingData); - const subRoute = extract('sub_route', remainingData); - - // Location - const storeLocation = extract('store_location', remainingData); - let lat = null; - let lng = null; - if (storeLocation) { - let locObj = storeLocation; - if (typeof locObj === 'string') { - try { locObj = JSON.parse(locObj); } catch (e) {} - } - if (locObj && typeof locObj === 'object') { - lat = (locObj as any).latitude; - lng = (locObj as any).longitude; - } - } - - // Potential - const potential = extract('potential', remainingData); - - // Image - const storeImage = extract('store_image', remainingData); - - // Meta - const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); - const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-'; - - const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')); - const userObj = userIdKey ? extract(userIdKey, remainingData) : null; - const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-'; - - // Helper Row - const Row = ({ label, value }: { label: string, value: any }) => { - let finalValue = value; - if (finalValue && typeof finalValue === 'object') { - try { finalValue = JSON.stringify(finalValue); } catch (e) { finalValue = '-'; } - } - return ( -
- {label}: - {formatValue(finalValue)} -
- ); - }; - - return ( -
- - {/* 1. Status Card */} - -
-
-
Status
-
- {stateName} -
-
-
-
- - {/* 2. Store Overview Card */} - -
- -

Store Overview

-
-
- - - - - - -
-
- - {/* 3. Owner Information Card */} - -
- -

Owner Information

-
-
- - - -
-
- - {/* 4. Distributor Details Card */} - -
- -

Distributor Details

-
-
- - - - -
-
- - {/* 5. Route Details Card */} - -
- -

Route Details

-
-
- - - -
-
- - {/* 6. Potential Card */} - {Array.isArray(potential) && potential.length > 0 && ( - -
- -

Potential

-
-
- -
-
- )} - - {/* 7. Store Image Card */} - {Array.isArray(storeImage) && storeImage.length > 0 && ( - -
- -

Store Image

-
-
-
- {storeImage.map((file: any, idx: number) => { - const previewUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${file.uuid}/preview`; - return ( -
- {file.original_name { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).parentElement!.innerHTML = `${file.original_name || 'File'}`; - }} - /> - -
- ); - })} -
-
-
- )} - - {/* 7.5 Store Location Map */} - {lat && lng && ( - -
- -

Store Location

-
-
- -
-
- )} - - {/* 8. Log Visit Details Fallback & Meta */} - {(Object.keys(remainingData).filter(k => !k.includes('uuid') && !/^\d+$/.test(k)).length > 0 || userName !== '-') && ( - -
- -

Other Details

-
-
- - - {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 ; - })} -
-
- )} - -
- ); +export interface StoreDetailProps { + instanceId: string | number; + onBack?: () => void; + onEdit?: () => void; +} + +export function StoreDetail({ instanceId, onBack, onEdit }: StoreDetailProps) { + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + async function run() { + setLoading(true); + setError(null); + try { + const r = await storeClient.detailView(STORE.detailViews.STORE, instanceId); + if (live) setData(r.data || {}); + } catch (e) { + if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); + } finally { + if (live) setLoading(false); + } + } + run(); + return () => { live = false; }; + }, [instanceId]); + + if (error) { + return ( + + + + ); + } + + if (loading) { + return ( + +
+
+ ); + } + + if (!data || Object.keys(data).length === 0) { + return ( + + + + ); + } + + const remainingData = { ...data }; + const extract = (key: string, obj: any = data) => { + if (obj && key in obj) { + const val = obj[key]; + delete obj[key]; + return val; + } + return null; + }; + + // State + const stateName = extract('current_state_name', remainingData) || '-'; + delete remainingData.current_state_id; + const isSuccess = ['created', 'active', 'approve'].some(s => String(stateName).toLowerCase().includes(s)); + const badgeClass = isSuccess ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'; + + // Store Overview + const storeCode = extract('store_code', remainingData) || '-'; + const businessName = extract('business_name', remainingData) || '-'; + const area = extract('area', remainingData); + const completeAddress = extract('complete_address', remainingData); + const pinCode = extract('pin_code', remainingData); + + // Owner Info + const ownerName = extract('owner_name', remainingData) || '-'; + const email = extract('email', remainingData); + let phoneNumber = extract('phone_number', remainingData); + let dialPhone = ''; + if (typeof phoneNumber === 'object' && phoneNumber !== null) { + dialPhone = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || ''; + phoneNumber = (phoneNumber as any).phone_with_dial_code || (phoneNumber as any).phone || '-'; + } else { + phoneNumber = phoneNumber || '-'; + dialPhone = String(phoneNumber); + } + + // Distributor Info + const distributorName = extract('distributor_name', remainingData); + const distributorOwnerName = extract('distributor_owner_name', remainingData); + const distributorEmail = extract('distributor_email', remainingData); + let distributorPhone = extract('distributor_phone_number', remainingData); + if (typeof distributorPhone === 'object' && distributorPhone !== null) { + distributorPhone = (distributorPhone as any).phone_with_dial_code || (distributorPhone as any).phone || '-'; + } else { + distributorPhone = distributorPhone || '-'; + } + + // Route Info + const routeCode = extract('route_code', remainingData); + const routeName = extract('route_name', remainingData); + const subRoute = extract('sub_route', remainingData); + + // Location + const storeLocation = extract('store_location', remainingData); + let lat = null; + let lng = null; + if (storeLocation) { + let locObj = storeLocation; + if (typeof locObj === 'string') { + try { locObj = JSON.parse(locObj); } catch (e) { } + } + if (locObj && typeof locObj === 'object') { + lat = (locObj as any).latitude; + lng = (locObj as any).longitude; + } + } + + // Potential + const potential = extract('potential', remainingData); + let totalPotential = 0; + if (Array.isArray(potential)) { + potential.forEach((p: any) => { + totalPotential += (Number(p.quantity) || 0); + }); + } + + // Image + const storeImage = extract('store_image', remainingData); + let imageUrl = ''; + if (Array.isArray(storeImage) && storeImage.length > 0) { + imageUrl = `${storeClient.baseUrl}/app/${APP_ID}/view/files/${storeImage[0].uuid}/preview`; + } + + // Meta + const createdAtKey = Object.keys(remainingData).find(k => k.endsWith('__created_at')); + const createdAt = createdAtKey ? extract(createdAtKey, remainingData) : '-'; + + const userIdKey = Object.keys(remainingData).find(k => k.endsWith('__user_id')); + const userObj = userIdKey ? extract(userIdKey, remainingData) : null; + const userName = userObj && typeof userObj === 'object' ? (userObj as any).name || (userObj as any).email : '-'; + + return ( +
+ {/* Top Bar */} +
+
+ {onBack && ( + + )} +
+ +
+ {String(storeCode)} +
+
+ {dialPhone && dialPhone !== '-' && ( + + )} + {lat && lng && ( + + )} + {onEdit && ( + + )} +
+
+ + {/* Hero Card */} +
+ {/* Image Section */} + {imageUrl ? ( +
+ {String(businessName)} +
+ ) : ( +
+ +
+ )} + + {/* Content Section */} +
+
+
+
+ + {String(stateName)} +
+
+ + {String(storeCode)} +
+
+ +

{String(businessName)}

+ +
+ + + {[completeAddress, area].filter(Boolean).join(', ')} + {pinCode ? ` — ${pinCode}` : ''} + +
+
+ + {/* Bottom Stats Row */} +
+
+
Owner
+
{String(ownerName)}
+
+
+
Route
+
{String(routeName || '-')}{subRoute ? ` · ${subRoute}` : ''}
+
+
+
Potential
+
+ {totalPotential} units +
+
+
+
+
+ + {/* Grid Layout */} +
+ + {/* Left Column (Spans 2) */} +
+ + {/* Contact Card */} + +
+ +

Contact

+
+
+
+
+ +
+
+
Owner
+
{String(ownerName)}
+
+
+
+
+ +
+
+
Phone
+
{String(phoneNumber)}
+
+
+
+
+ +
+
+
Email
+
{email ? String(email) : '-'}
+
+
+
+
+ +
+
+
Address
+
+ {[completeAddress, area, pinCode].filter(Boolean).join(', ')} +
+
+
+
+
+ + {/* Route Assignment Card */} + +
+ +

Route Assignment

+
+
+
+
Route
+
{routeName ? String(routeName) : '-'}
+
Code {routeCode ? String(routeCode) : '-'}
+
+
+
Sub Route
+
{subRoute ? String(subRoute) : '-'}
+
+
+
Area
+
{area ? String(area) : '-'}
+
PIN {pinCode ? String(pinCode) : '-'}
+
+
+
+ + {/* Order Potential Card */} + {Array.isArray(potential) && potential.length > 0 && ( + +
+ +

Order Potential

+
+
+ + + + + + + + + {potential.map((p: any, idx: number) => { + if (!p.product_category && !p.quantity) return null; + return ( + + + + + ); + })} + + + + + +
Product CategoryQuantity
{p.product_category ? String(p.product_category) : '-'}{p.quantity ? String(p.quantity) : '0'}
Total{totalPotential}
+
+
+ )} + + {/* Distributor Card */} + +
+ +

Distributor

+
+
+
+
{distributorName ? String(distributorName) : '-'}
+
Owner · {distributorOwnerName ? String(distributorOwnerName) : '-'}
+ +
+
+
+ +
+
+
Phone
+
{String(distributorPhone)}
+
+
+
+
+ +
+
+
Email
+
{distributorEmail ? String(distributorEmail) : '-'}
+
+
+
+
+
+
+ +
+ + {/* Right Column (Spans 1) */} +
+ + {/* Location Map */} + +
+ +

Location

+
+
+ {lat && lng ? ( + <> + +
window.open(`https://maps.google.com/maps?q=${lat},${lng}`, '_blank')} + > + +
+ + ) : ( +
+ + No location data +
+ )} +
+ {lat && lng && ( +
+ Tap map icon to open in Google Maps. +
+ )} +
+ + {/* Meta */} + +
+ +

Meta

+
+
+
+ Store Code + {String(storeCode)} +
+
+ Instance + #{instanceId} +
+
+ Created by + {String(userName)} +
+
+ Created at + {createdAt !== '-' ? new Date(createdAt as string).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-'} +
+
+
+ +
+
+ +
+ ); } diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index aba237f..f7a6098 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -120,6 +120,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); + const [hasSubmitted, setHasSubmitted] = useState(false); const [submitError, setSubmitError] = useState(null); const [showErrors, setShowErrors] = useState(false); @@ -133,6 +134,51 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const next = { ...prev, [fieldId]: newVal }; if (fullRow) { next[`${fieldId}_row`] = fullRow; + + // Auto-fill mapped fields based on the raw row data only for route_name + if (fieldId === 'route_name_2' || fieldId.startsWith('route_name')) { + schema?.fields.forEach(f => { + if (f.id !== fieldId) { + const mappedName = (f.mapped_workflow_field || '').toLowerCase(); + const nameFallback = (f.name || '').toLowerCase().replace(/\s+/g, '_'); + const fieldName = mappedName || nameFallback; + + let matchKey = Object.keys(fullRow).find(k => k.toLowerCase() === mappedName || k.toLowerCase() === nameFallback); + + // Special handling for route_name -> distributor details + if (!matchKey) { + matchKey = Object.keys(fullRow).find(k => + fieldName === `distributor_${k.toLowerCase()}` || + (k.toLowerCase() === 'phone' && fieldName === 'distributor_phone_number') || + (k.toLowerCase() === 'email' && fieldName === 'distributor_email') + ); + } + + if (matchKey && fullRow[matchKey] !== undefined && fullRow[matchKey] !== null) { + if (matchKey.toLowerCase() === 'owner_name' && fieldName === 'owner_name') { + return; + } + if (matchKey.toLowerCase() === 'email' && fieldName === 'email') { + return; + } + if (matchKey.toLowerCase() === 'phone' && (fieldName === 'phone' || fieldName === 'phone_number')) { + return; + } + + let fillVal = fullRow[matchKey]; + // If the target field is a select, try to match option value case-insensitively + if (f.data_type === 'select' && f.properties?.options) { + const matchedOpt = f.properties.options.find((o: any) => String(o.value).toLowerCase() === String(fillVal).toLowerCase()); + if (matchedOpt) { + fillVal = matchedOpt.value; + } + } + + next[f.id] = fillVal; + } + } + }); + } } // Auto-calculate order_details totals @@ -207,20 +253,31 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const merged: Record[] = []; const indexMap = new Map(); + const getGridVal = (obj: Record, searchTokens: string[], excludeTokens: string[] = []) => { + for (const k of Object.keys(obj)) { + const lowerK = k.toLowerCase(); + if (searchTokens.some(t => lowerK.includes(t)) && !excludeTokens.some(t => lowerK.includes(t))) { + return obj[k]; + } + } + return undefined; + }; + for (const r of rawGrid) { - const cat = String(r.category || r.product_category || '').trim().toLowerCase(); - const prod = String(r.productName || r.product_name || r.product || '').trim().toLowerCase(); - const currentBags = Number(r.bags || r.quantity || 0); + const cat = String(getGridVal(r, ['category', 'cat']) || '').trim().toLowerCase(); + const prod = String(getGridVal(r, ['product', 'name'], ['category', 'cat']) || '').trim().toLowerCase(); + const currentBags = Number(getGridVal(r, ['bags', 'quantity'])) || 0; + const currentSku = Number(getGridVal(r, ['sku'], ['code'])) || 0; if (cat && prod) { const key = `${cat}::${prod}`; if (indexMap.has(key)) { const targetIdx = indexMap.get(key)!; const existingRow = merged[targetIdx]; - const existingBags = Number(existingRow.bags || existingRow.quantity || 0); + const existingBags = Number(getGridVal(existingRow, ['bags', 'quantity'])) || 0; const addedBags = currentBags > 0 ? currentBags : 1; const newBags = existingBags + addedBags; - const skuVal = Number(existingRow.sku || r.sku || 0); + const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0); merged[targetIdx] = { ...existingRow, @@ -253,8 +310,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: let totalB = 0; let totalK = 0; cleanGrid.forEach((r) => { - const b = Number(r.bags || r.quantity) || 0; - const sku = Number(r.sku) || 0; + const b = Number(getGridVal(r, ['bags', 'quantity'])) || 0; + const sku = Number(getGridVal(r, ['sku'], ['code'])) || 0; totalB += b; totalK += b * sku; }); @@ -322,16 +379,32 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: for (const f of normalFields) { if (f.data_type.startsWith('grid')) { + if (currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56') { + continue; + } + const gridRows = (finalValues[f.id] as Record[]) || []; + const hasProdCol = f.columns?.some(c => + c.id.toLowerCase().includes('product_name') || + c.id.toLowerCase() === 'product' || + c.id.toLowerCase() === 'name' || + (c.name || '').toLowerCase().includes('product name') || + (c.name || '').toLowerCase() === 'product' || + ((c.name || '').toLowerCase().includes('product') && !(c.name || '').toLowerCase().includes('category')) + ); + // Check if grid has at least one valid row const validRows = gridRows.filter((r) => { const { cat, prod, bags } = extractGridRowDetails(r, f.columns); - return cat && prod && bags > 0; + if (hasProdCol) { + return cat && prod && bags > 0; + } + return cat && bags > 0; }); - if (validRows.length === 0) { - setSubmitError('Please add at least one complete product with Category, Product Name, and Bags.'); + if (f.mandatory && validRows.length === 0) { + setSubmitError(`Please add at least one complete row for ${f.name}.`); return; } @@ -339,6 +412,12 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: const hasIncompleteRow = gridRows.some((r) => { const { cat, prod, bags } = extractGridRowDetails(r, f.columns); + if (!hasProdCol) { + if (cat && bags <= 0) return true; + if (bags > 0 && !cat) return true; + return false; + } + if (!cat && !prod && bags <= 0) return false; if (cat && (!prod || bags <= 0)) return true; if (prod && (!cat || bags <= 0)) return true; @@ -347,7 +426,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: }); if (hasIncompleteRow) { - setSubmitError('Please complete all required product details (Product Name and Bags) before submitting.'); + if (!hasProdCol) { + setSubmitError(`Please complete all required details (Category and Quantity) for ${f.name} before submitting.`); + } else { + setSubmitError('Please complete all required product details (Category, Product Name, and Bags) before submitting.'); + } return; } } @@ -398,6 +481,15 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: } else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) { const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); payload[f.id] = [fileMeta]; + } else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') { + const gridRows = Array.isArray(val) ? val : []; + const cleanRows = gridRows.filter(r => { + return Object.keys(r).some(k => { + const v = r[k]; + return v !== undefined && v !== null && String(v).trim() !== ''; + }); + }); + payload[f.id] = cleanRows; } else { payload[f.id] = val; } @@ -410,6 +502,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: res = await client.startInstance(currentActivityId, payload); } + setHasSubmitted(true); + let pending = [...chainQueue]; let chainSource = (res as any).activity_chain || schema.activity_chain || []; @@ -579,7 +673,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: required={f.mandatory} value={(val as string) ?? ''} options={f.properties?.options || []} - onChange={(newVal) => handleFieldChange(f.id, newVal)} + onChange={(newVal, fullRow) => handleFieldChange(f.id, newVal, fullRow)} /> ); } @@ -609,6 +703,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: } if (type.startsWith('grid')) { + const isPotentialMiningGrid = currentActivityId === 'af8ac8df-d868-4f7d-88b2-123955d69c56'; return ( ); } @@ -718,7 +817,13 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
{onCancel && ( - )} diff --git a/src/components/forms/fields/GeolocationInput.tsx b/src/components/forms/fields/GeolocationInput.tsx index 17e9150..d753643 100644 --- a/src/components/forms/fields/GeolocationInput.tsx +++ b/src/components/forms/fields/GeolocationInput.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Button } from '../../buttons/Button'; export function GeolocationInput({ @@ -15,6 +15,8 @@ export function GeolocationInput({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const autoFetchDone = useRef(false); + const fetchLocation = () => { if (!navigator.geolocation) { setError('Geolocation is not supported by your browser.'); @@ -38,21 +40,34 @@ export function GeolocationInput({ ); }; + useEffect(() => { + if (!value && !autoFetchDone.current) { + autoFetchDone.current = true; + fetchLocation(); + } + }, [value]); + + const mapCenter = value + ? new L.LatLng(value.latitude, value.longitude) + : new L.LatLng(20.5937, 78.9629); // Default to India roughly if no location + return ( -
+
{label} {required && *} -
- - {value && ( - - {value.latitude.toFixed(5)}, {value.longitude.toFixed(5)} - - )} +
+
+ + {value && ( + + {value.latitude.toFixed(5)}, {value.longitude.toFixed(5)} + + )} +
{error && {error}} {required && !value && } diff --git a/src/components/forms/fields/OrderGrid.tsx b/src/components/forms/fields/OrderGrid.tsx new file mode 100644 index 0000000..3822870 --- /dev/null +++ b/src/components/forms/fields/OrderGrid.tsx @@ -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[]; + onChange: (val: Record[]) => 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[]) => { + const merged: Record[] = []; + const indexMap = new Map(); + + 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 = { + ...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 = {}; + 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 = {}; + 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 = {}; + 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 ( +
+
+

+ {label || 'Order details'} +

+
+ +
+
+ {visibleColumns.map((col) => ( + {col.name} + ))} + +
+ +
+ {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 ( +
+
+ {visibleColumns.map((col) => { + const fieldId = col.id; + + if (fieldId === '_row_total_kgs') { + return ( + 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 ( + 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 ( + 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 ( + updateRowField(rowIdx, fieldId, e.target.value)} + className="w-full" + /> + ); + })} + + +
+
+ ); + })} +
+ +
+ +
+ + {bagsCol && ( +
+
+ {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 ( + + ); + } + + if (fieldId === '_row_total_kgs') { + return ( + + ); + } + + if (idx === 0) { + return
Total
; + } + + return
; + })} +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/forms/fields/PotentialMiningGrid.tsx b/src/components/forms/fields/PotentialMiningGrid.tsx new file mode 100644 index 0000000..2985f65 --- /dev/null +++ b/src/components/forms/fields/PotentialMiningGrid.tsx @@ -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[]; + onChange: (val: Record[]) => 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 ( +
+
+

+ {label || 'Potential Mining'} +

+
+ +
+
+ {visibleColumns.map((col) => ( + {col.name} + ))} +
+ +
+ {rows.map((row, rowIdx) => { + const currentCatVal = String(row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? ''); + + return ( +
+
+ {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 ( +
+ {displayStr} +
+ ); + } + + 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 ( + 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 ( + updateRowField(rowIdx, fieldId, e.target.value)} + className="w-full" + /> + ); + })} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/forms/fields/SelectField.tsx b/src/components/forms/fields/SelectField.tsx index 4848a4f..a3926f5 100644 --- a/src/components/forms/fields/SelectField.tsx +++ b/src/components/forms/fields/SelectField.tsx @@ -11,14 +11,18 @@ export function SelectField({ required?: boolean; value: string; options: any[]; - onChange: (val: string) => void; + onChange: (val: string, fullRow?: any) => void; }) { return ( 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 ( - 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 ( - 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 ( - updateRowField(rowIdx, fieldId, e.target.value)} - className="w-full" - /> - ); - })} - - {/* Remove Row Button */} - -
-
- ); - })} -
- - {/* Grid Footer - Positions the actual total_bags and total_kgs form fields right under Bags & Total kgs columns */} -
-
- {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 ( - - ); - } - - if (fieldId === '_row_total_kgs') { - return ( - - ); - } - - if (idx === 0) { - return ( -
- Total -
- ); - } - - return
; - })} -
-
-
-
-
- ); + // Use StorePotentialGrid for simpler grids like Store adding potential + return ; } diff --git a/src/components/forms/fields/StorePotentialGrid.tsx b/src/components/forms/fields/StorePotentialGrid.tsx new file mode 100644 index 0000000..a9045da --- /dev/null +++ b/src/components/forms/fields/StorePotentialGrid.tsx @@ -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[]; + onChange: (val: Record[]) => 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[]) => { + const merged: Record[] = []; + const indexMap = new Map(); + + 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 = { + ...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 ( +
+
+

+ {label || 'Store Potential'} +

+
+ +
+
+ {visibleColumns.map((col) => ( + {col.name} + ))} + +
+ +
+ {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 ( +
+
+ {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 ( + 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 ( + updateRowField(rowIdx, fieldId, e.target.value)} + className="w-full" + /> + ); + })} + + +
+
+ ); + })} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/forms/fields/gridUtils.ts b/src/components/forms/fields/gridUtils.ts new file mode 100644 index 0000000..eecfbed --- /dev/null +++ b/src/components/forms/fields/gridUtils.ts @@ -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) + ); + }); + }); +}; diff --git a/src/components/reusable/AnalyticsChart.tsx b/src/components/reusable/AnalyticsChart.tsx index c29377e..812da64 100644 --- a/src/components/reusable/AnalyticsChart.tsx +++ b/src/components/reusable/AnalyticsChart.tsx @@ -40,7 +40,7 @@ export function AnalyticsChart({ data }: AnalyticsChartProps) { const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6']; return ( -
+
{charts.map((chart, idx) => { const title = (chart.key || `Chart ${idx + 1}`) .replace(/_/g, ' ') diff --git a/src/screens/CallsPage.tsx b/src/screens/CallsPage.tsx index 8710063..3094694 100644 --- a/src/screens/CallsPage.tsx +++ b/src/screens/CallsPage.tsx @@ -4,7 +4,7 @@ import { CallsView } from '../components/rv'; import { CallDetail } from '../components/dv'; import { useEffect, useState } from 'react'; import { Button } from '../components/buttons/Button'; -import { Plus } from 'lucide-react'; +import { Plus, ArrowLeft } from 'lucide-react'; import { DynamicForm } from '../components/forms/DynamicForm'; import { ORDER_BOOKING } from '../api/config'; import { orderBookingClient } from '../api/clients'; @@ -80,54 +80,14 @@ export function CallsPage() { } }; - return ( - <> - { - setSelectedRow(row); - const id = row.instance_id as number | string | undefined; - if (id != null) navigate(`/calls/${id}`); - }} - headerActions={ - - } - /> - - setIsCreating(false)} - title={createTitle} - width="md" - > - { - setIsCreating(false); - setRefreshKey(k => k + 1); - }} - onCancel={() => setIsCreating(false)} - onActivityChange={setCreateTitle} - /> - - - navigate(`/calls`)} - title={instanceId != null ? `Call #${instanceId}` : undefined} - width="lg" - > - {instanceId != null && selectedRow && ( -
+ if (instanceId != null) { + return ( +
+
+ {selectedRow ? ( navigate('/calls')} selectedRow={selectedRow} refreshKey={refreshKey} potentialMiningAction={ @@ -167,32 +127,75 @@ export function CallsPage() { })() } /> -
- )} - + ) : ( +
Loading call details...
+ )} +
+ + {/* Active Activity Modal (for actions) */} + setActiveActivity(null)} + title={activeActivity?.name} + width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'} + > + {activeActivity && instanceId != null && ( + { + setActiveActivity(null); + setRefreshKey(k => k + 1); + }} + onCancel={() => setActiveActivity(null)} + onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)} + /> + )} + +
+ ); + } + + return ( + <> + { + setSelectedRow(row); + const id = row.instance_id as number | string | undefined; + if (id != null) navigate(`/calls/${id}`); + }} + headerActions={ + + } + /> setActiveActivity(null)} - title={activeActivity?.name} - width={activeActivity?.id === ORDER_BOOKING.activities.POTENTIAL_MINING.uid ? 'lg' : 'md'} + open={isCreating} + onClose={() => setIsCreating(false)} + title={createTitle} + width="md" > - {activeActivity && instanceId != null && ( - { - setActiveActivity(null); - setRefreshKey(k => k + 1); - }} - onCancel={() => setActiveActivity(null)} - onActivityChange={(name) => setActiveActivity(prev => prev ? { ...prev, name } : null)} - /> - )} + { + setIsCreating(false); + setRefreshKey(k => k + 1); + }} + onCancel={() => setIsCreating(false)} + onActivityChange={setCreateTitle} + /> ); diff --git a/src/screens/DailyLogsPage.tsx b/src/screens/DailyLogsPage.tsx index 7961bc0..dd32a8e 100644 --- a/src/screens/DailyLogsPage.tsx +++ b/src/screens/DailyLogsPage.tsx @@ -21,36 +21,38 @@ export function DailyLogsPage() { return ( <> - { - const id = row.instance_id as number | string | undefined; - if (id != null) navigate(`/daily/${id}`); - }} - headerActions={ - - } - rowActions={(row) => { - const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase(); - // Show only for Punched In, hide for Punched Out or others - if (state.includes('out') || !state.includes('in')) return null; - - return ( - - ); - }} - /> + } + rowActions={(row) => { + const state = String(row.current_state_name || row.current_state_name_ || row.current_state || row.status || '').toLowerCase(); + // Show only for Punched In, hide for Punched Out or others + if (state.includes('out') || !state.includes('in')) return null; + + return ( + + ); + }} + /> + )} - navigate(`/daily`)} - title={instanceId != null ? `Daily Log #${instanceId}` : undefined} - width="lg" - > - {instanceId != null && setPunchOutInstanceId(instanceId)} />} - + {instanceId != null && ( +
+ setPunchOutInstanceId(instanceId)} + onBack={() => navigate('/daily')} + /> +
+ )} ); } diff --git a/src/screens/DailySalesReportPage.tsx b/src/screens/DailySalesReportPage.tsx index 5074685..b9c326e 100644 --- a/src/screens/DailySalesReportPage.tsx +++ b/src/screens/DailySalesReportPage.tsx @@ -35,7 +35,7 @@ export function DailySalesReportPage() { doc.setFontSize(12); doc.setFont('helvetica', 'bold'); doc.text('DAILY ORDER REPORT', 105, 30, { align: 'center' }); - + // Simple underline for DAILY ORDER REPORT const textWidth = doc.getTextWidth('DAILY ORDER REPORT'); doc.setLineWidth(0.5); @@ -74,7 +74,7 @@ export function DailySalesReportPage() { doc.setFontSize(12); doc.setFont('helvetica', 'bold'); doc.text('DAILY ORDER REPORT', 105, 30, { align: 'center' }); - + // Simple underline for DAILY ORDER REPORT const textWidth = doc.getTextWidth('DAILY ORDER REPORT'); doc.setLineWidth(0.5); @@ -103,7 +103,7 @@ export function DailySalesReportPage() { iframe.style.display = 'none'; iframe.src = blobUrl; document.body.appendChild(iframe); - + iframe.onload = () => { setTimeout(() => { iframe.contentWindow?.focus(); @@ -157,28 +157,24 @@ export function DailySalesReportPage() { type="date" value={date} onChange={(e) => setDate(e.target.value)} - required /> setDistributor(e.target.value)} options={[{ value: '', label: 'Select Distributor' }, ...SALES_REPORT_DISTRIBUTORS.map(d => ({ value: d, label: d }))]} - required />