diff --git a/src/components/cards/CallCard.tsx b/src/components/cards/CallCard.tsx new file mode 100644 index 0000000..e275163 --- /dev/null +++ b/src/components/cards/CallCard.tsx @@ -0,0 +1,148 @@ +import { formatValue } from '../../lib/format'; + +export function CallCard({ row }: { row: Record }) { + const instanceId = row.instance_id || '—'; + + // Status + const stateName = String(row.status || row.current_state_name || 'Pending'); + const isProductive = stateName.toLowerCase().includes('productive') || stateName.toLowerCase().includes('completed') || stateName.toLowerCase().includes('success'); + + // Store Details + const storeName = formatValue(row.store || row.store_name || row.customer_name || 'Unknown Store'); + const storeCode = formatValue(row.store_code || '—'); + + // Grid Details + const totalWeight = formatValue(row.total_kgs || row.total_weight || row.weight || 0); + const totalQuantity = formatValue(row.total_bags || row.total_quantity || row.quantity || 0); + const totalOrders = formatValue(row.order_count || row.total_orders || 0); + + // Route & Assignment + const routeName = formatValue(row.route_name || row.route || '—'); + const areaName = formatValue(row.area || row.area_name || '—'); + + // User + const userName = formatValue(row.sales_officer || row.user_name || '—'); + const userEmail = formatValue(row.performed_by_email || row.user_email || '—'); + const initials = userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'SO'; + + // Date & Time + let dateStr = '—'; + let timeStr = '—'; + + if (row.visit_date) { + try { + const d = new Date(row.visit_date as string); + if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0]; + } catch { + dateStr = String(row.visit_date).split('T')[0]; + } + } + + if (row.visit_time) { + try { + const t = String(row.visit_time); + if (t.includes('T')) { + timeStr = t.split('T')[1].replace('Z', '').split('.')[0]; + } else { + timeStr = t; + } + } catch { + timeStr = String(row.visit_time); + } + } + + // Choose styling based on status + const statusBg = isProductive ? 'bg-emerald-50' : 'bg-amber-50'; + const statusBorder = isProductive ? 'border-emerald-100' : 'border-amber-100'; + const statusDot = isProductive ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'; + const statusText = isProductive ? 'text-emerald-700' : 'text-amber-700'; + + return ( +
+ {/* Header Status Bar */} +
+
+ + {stateName} +
+ + ID: {instanceId} + +
+ + {/* Main Content */} +
+ {/* Store Details */} +
+ Store Details +

{storeName}

+ {storeCode !== '—' && ( +

+ Code: {storeCode} +

+ )} +
+ + {/* Logistics Grid */} +
+
+

Total Weight

+

+ {totalWeight} Kgs +

+
+
+

Total Quantity

+

+ {totalQuantity} Bags +

+
+
+ Total Orders + + {totalOrders} {Number(totalOrders) === 1 ? 'Order' : 'Orders'} + +
+
+ + {/* Route & Assignment */} +
+
+

Route

+

{routeName}

+
+
+

Area

+

{areaName}

+
+
+

Sales Officer

+
+
+ {initials} +
+
+

{userName}

+ {userEmail !== '—' && ( +

{userEmail}

+ )} +
+
+
+
+
+ + {/* Footer Timeline */} +
+
+ + {dateStr} +
+
+ + {timeStr} +
+
+
+ ); +} diff --git a/src/components/cards/DailyLogCard.tsx b/src/components/cards/DailyLogCard.tsx new file mode 100644 index 0000000..9cc7fea --- /dev/null +++ b/src/components/cards/DailyLogCard.tsx @@ -0,0 +1,140 @@ +import { formatValue } from '../../lib/format'; +import { FileImage } from 'lucide-react'; + +export function DailyLogCard({ row, onPunchOut }: { row: Record; onPunchOut?: (row: any) => void }) { + const stateName = String(row.status || row.current_state_name || 'Logged'); + + // User details + const userObj = (row.sales_officer_name || row.user_id || row.user) as Record | null; + const userName = formatValue(userObj?.name || row.user_name || 'Unknown User'); + const userEmail = formatValue(userObj?.email || row.user_email || '—'); + const initials = userName !== 'Unknown User' && userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'US'; + + // Punch Details + let dateStr = '—'; + let timeStr = '—'; + + if (row.date) dateStr = String(row.date).split('T')[0]; + else if (row.created_at) dateStr = String(row.created_at).split('T')[0]; + + if (row.time) timeStr = String(row.time).split('T')[1]?.split('.')[0] || String(row.time); + else if (row.created_at) timeStr = String(row.created_at).split('T')[1]?.split('.')[0] || '—'; + + const routeCode = formatValue(row.route_code || row.route || '—'); + const dayPlanNotes = formatValue(row.day_plan_notes || row.notes || row.plan || 'No notes provided.'); + + // Store Image / Verification Media + const imageArray = row.store_image || row.image || row.verification_media; + const imgData = Array.isArray(imageArray) && imageArray.length > 0 ? imageArray[0] : null; + + let imgName = 'None attached'; + let imgSize = '—'; + let imgMime = '—'; + + if (imgData && typeof imgData === 'object') { + imgName = imgData.original_name || imgData.name || 'attachment.png'; + const sizeBytes = imgData.size_bytes || imgData.size || 0; + imgSize = sizeBytes > 0 ? `${(sizeBytes / 1024).toFixed(1)} KB` : 'Unknown size'; + imgMime = imgData.mime_type || imgData.type || 'image/jpeg'; + } + + const hasImage = imgName !== 'None attached'; + + // Dynamic styling based on status + const isPunchedIn = stateName.toLowerCase().includes('in') || stateName.toLowerCase().includes('active'); + const statusBg = isPunchedIn ? 'bg-amber-50' : 'bg-slate-50'; + const statusBorder = isPunchedIn ? 'border-amber-100' : 'border-slate-200'; + const statusDot = isPunchedIn ? 'bg-amber-500 animate-pulse' : 'bg-slate-400'; + const statusText = isPunchedIn ? 'text-amber-800' : 'text-slate-600'; + + return ( +
+ + {/* Attendance Status Header */} +
+
+ + {stateName} +
+
+ +
+ + {/* Profile and Account Info */} +
+
+ {initials} +
+
+

{userName}

+ {userEmail !== '—' && ( +

{userEmail}

+ )} +
+
+ + {/* Punch Details Grid */} +
+
+ Punch Date +

{dateStr}

+
+
+ Punch Time +

{timeStr}

+
+
+ Route Assigned + + Route: {routeCode} + +
+
+ + {/* Day Plan & Notes */} +
+ Day Plan Summary +
+

+ "{dayPlanNotes}" +

+
+
+ + {/* Attached Verification Media */} + {hasImage && ( +
+ Verification Image +
+
+
+ +
+
+

{imgName}

+

{imgSize}

+
+
+ + MIME: {imgMime.split('/')[1] || 'Media'} + +
+
+ )} + + {/* Inline Punch Out Action */} + {onPunchOut && isPunchedIn && ( +
+ +
+ )} + +
+
+ ); +} diff --git a/src/components/cards/StoreCard.tsx b/src/components/cards/StoreCard.tsx new file mode 100644 index 0000000..2b36590 --- /dev/null +++ b/src/components/cards/StoreCard.tsx @@ -0,0 +1,202 @@ +import { formatValue } from '../../lib/format'; +import { Edit2 } from 'lucide-react'; + +export function StoreCard({ row, fields, onEdit }: { row: Record; fields: any[]; onEdit?: (row: any) => void }) { + // Status + const stateName = String(row.current_state_name || row.status || 'Active'); + const isActive = stateName.toLowerCase().includes('active') || stateName.toLowerCase().includes('created') || stateName.toLowerCase().includes('approved'); + + // Store Details + const storeName = formatValue(row.business_name || row.store_name || row.name || 'Unknown Store'); + const storeCode = formatValue(row.store_code || row.code || '—'); + const ownerName = formatValue(row.owner_name || row.contact_person || '—'); + const phoneObj = row.phone_number as Record | null; + const phone = formatValue(phoneObj?.phone_with_dial_code || phoneObj?.phone || row.phone || row.mobile || '—'); + + // Route Info + const routeName = formatValue(row.route_name || row.route || '—'); + const subRouteName = formatValue(row.sub_route || row.sub_route_name || '—'); + const routeCode = formatValue(row.route_code || routeName.charAt(0) || 'R'); + + // Distributor Info + const distName = formatValue(row.distributor_name || '—'); + const distContact = formatValue(row.distributor_owner_name || '—'); + const distPhoneObj = row.distributor_phone_number as Record | null; + const distPhone = formatValue(distPhoneObj?.phone_with_dial_code || distPhoneObj?.phone || row.distributor_phone || '—'); + + // Address + const address = formatValue(row.complete_address || row.address || '—'); + const pin = formatValue(row.pin_code || row.pincode || '—'); + const locObj = row.store_location as Record | null; + const lat = formatValue(locObj?.latitude || row.latitude || '—'); + const lng = formatValue(locObj?.longitude || row.longitude || '—'); + const areaName = formatValue(row.area || row.area_name || '—'); + + // User + const userKey = Object.keys(row).find(k => k.includes('user_id') || k.includes('created_by') || k.includes('sales_officer')); + const userObj = userKey ? (typeof row[userKey] === 'object' ? row[userKey] : null) : null; + const userName = formatValue(userObj?.name || row.sales_officer || row.user_name || '—'); + const userEmail = formatValue(userObj?.email || row.user_email || '—'); + const initials = userName !== '—' ? String(userName).substring(0, 2).toUpperCase() : 'SO'; + + // Date + let dateStr = '—'; + const dateKey = Object.keys(row).find(k => k.includes('created_at')); + const rawDate = dateKey ? row[dateKey] : (row.created_at || row.date || row.registration_date); + if (rawDate) { + try { + const d = new Date(rawDate as string); + if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0]; + else dateStr = String(rawDate).split('T')[0]; + } catch { + dateStr = String(rawDate).split('T')[0]; + } + } + + // Potential (grid) + const potentialKey = Object.keys(row).find(k => k.includes('potential') || k.includes('inventory')); + let potentials: any[] = []; + if (potentialKey && Array.isArray(row[potentialKey])) { + potentials = row[potentialKey]; + } else { + // try to find any grid + const gridField = fields.find(f => f.data_type === 'grid'); + if (gridField && Array.isArray(row[gridField.field_key])) { + potentials = row[gridField.field_key]; + } + } + + const statusBg = isActive ? 'bg-blue-50' : 'bg-slate-50'; + const statusBorder = isActive ? 'border-blue-100' : 'border-slate-200'; + const statusDot = isActive ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'; + const statusText = isActive ? 'text-blue-700' : 'text-slate-600'; + + return ( +
+ {/* Header Status Bar */} +
+
+ + {storeCode} +
+ +
+ + {/* Main Content */} +
+ {/* Store Identity */} +
+ Store Details +

{storeName}

+ + {/* Status Badge for Route/Sub-route */} + {(routeName !== '—' || subRouteName !== '—') && ( +
+ + R: {routeName} ({String(routeCode).substring(0, 1)}) + {subRouteName !== '—' && ` • Sub: ${subRouteName}`} + +
+ )} + + {/* Contact Info */} + {(ownerName !== '—' || phone !== '—') && ( +
+ {ownerName !== '—' && ( +

+ Owner + {ownerName} +

+ )} + {phone !== '—' && ( +

+ Phone + {phone} +

+ )} +
+ )} +
+ + {/* Fulfillment / Distributor */} + {distName !== '—' && ( +
+

Assigned Distributor

+
+

{distName}

+ {distContact !== '—' &&

Contact: {distContact}

} + {distPhone !== '—' &&

{distPhone}

} +
+
+ )} + + {/* Product Potential Breakdown */} + {potentials.length > 0 && ( +
+

Inventory Potential

+
+ {potentials.map((item, idx) => { + const prodName = formatValue(item.product_name || item.product_category || item.product || `Item ${idx+1}`); + const qty = formatValue(item.quantity || item.qty || item.potential || item.bags || 0); + return ( +
+ {prodName} + {qty} Qty +
+ ); + })} +
+
+ )} + + {/* Logistics & Address */} +
+
+

Address

+

{address}

+ {(pin !== '—' || lat !== '—') && ( +

+ {pin !== '—' && `PIN: ${pin}`} {pin !== '—' && lat !== '—' && '|'} {lat !== '—' && `Loc: ${lat}, ${lng}`} +

+ )} +
+
+

Created By

+
+
+ {initials} +
+
+

{userName}

+ {userEmail !== '—' && ( +

{userEmail}

+ )} +
+
+
+
+
+ + {/* Footer Timestamp */} +
+
+ + Registered: {dateStr} +
+ {areaName !== '—' && ( + + Area: {areaName} + + )} +
+
+ ); +} diff --git a/src/components/rv/CallsView.tsx b/src/components/rv/CallsView.tsx index e6f1c34..b87b704 100644 --- a/src/components/rv/CallsView.tsx +++ b/src/components/rv/CallsView.tsx @@ -2,6 +2,7 @@ import { orderBookingClient } from '../../api/clients'; import { ORDER_BOOKING } from '../../api/config'; import { RecordView } from './RecordView'; import type { WiredRecordViewProps } from './OrdersView'; +import { CallCard } from '../cards/CallCard'; /** Calls record view (Calls/Visits workflow). */ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) { @@ -15,6 +16,9 @@ export function CallsView({ onRowClick, pageSize, headerActions, rowActions, ref headerActions={headerActions} rowActions={rowActions} refreshKey={refreshKey} + sortBy="instance_id" + sortDir="desc" + renderItem={(row) => } /> ); } diff --git a/src/components/rv/DailyLogsView.tsx b/src/components/rv/DailyLogsView.tsx index 780b5dd..8e81dcb 100644 --- a/src/components/rv/DailyLogsView.tsx +++ b/src/components/rv/DailyLogsView.tsx @@ -2,9 +2,10 @@ import { dailyReportsClient } from '../../api/clients'; import { DAILY_REPORTS } from '../../api/config'; import { RecordView } from './RecordView'; import type { WiredRecordViewProps } from './OrdersView'; +import { DailyLogCard } from '../cards/DailyLogCard'; /** Daily Logs record view (Daily Reports workflow). */ -export function DailyLogsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) { +export function DailyLogsView({ onRowClick, onPunchOutRow, pageSize, headerActions, refreshKey }: WiredRecordViewProps & { onPunchOutRow?: (row: any) => void }) { return ( } /> ); } diff --git a/src/components/rv/OrdersView.tsx b/src/components/rv/OrdersView.tsx index 398636a..7fd3458 100644 --- a/src/components/rv/OrdersView.tsx +++ b/src/components/rv/OrdersView.tsx @@ -23,6 +23,8 @@ export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, re headerActions={headerActions} rowActions={rowActions} refreshKey={refreshKey} + sortBy="instance_id" + sortDir="desc" renderItem={(row, fields) => } /> ); diff --git a/src/components/rv/RecordView.tsx b/src/components/rv/RecordView.tsx index 6548751..09722e0 100644 --- a/src/components/rv/RecordView.tsx +++ b/src/components/rv/RecordView.tsx @@ -34,6 +34,10 @@ export interface RecordViewProps { refreshKey?: number; /** Custom render function for the entire row card body */ renderItem?: (row: Record, fields: RecordViewField[]) => React.ReactNode; + /** Sort by column key */ + sortBy?: string; + /** Sort direction */ + sortDir?: 'asc' | 'desc'; } /** @@ -53,6 +57,8 @@ export function RecordView({ rowActions, refreshKey, renderItem, + sortBy, + sortDir, }: RecordViewProps) { const [page, setPage] = useState(1); const [search, setSearch] = useState(''); @@ -91,7 +97,7 @@ export function RecordView({ setLoading(true); setError(null); try { - const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam }); + const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam, sortBy, sortDir }); if (live) setResp(r); } catch (e) { if (live) setError((e as { message?: string })?.message ?? 'Failed to load'); @@ -103,7 +109,7 @@ export function RecordView({ return () => { live = false; }; - }, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey]); + }, [client, rvUid, page, pageSize, debounced, filtersParam, refreshKey, sortBy, sortDir]); const fields: RecordViewField[] = useMemo(() => { const all = resp?.config.fields ?? []; diff --git a/src/components/rv/StoresView.tsx b/src/components/rv/StoresView.tsx index 5686dfd..1273396 100644 --- a/src/components/rv/StoresView.tsx +++ b/src/components/rv/StoresView.tsx @@ -2,9 +2,10 @@ import { storeClient } from '../../api/clients'; import { STORE } from '../../api/config'; import { RecordView } from './RecordView'; import type { WiredRecordViewProps } from './OrdersView'; +import { StoreCard } from '../cards/StoreCard'; /** Store record view (Store workflow). */ -export function StoresView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode }) { +export function StoresView({ onRowClick, onEditRow, pageSize, headerActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode; onEditRow?: (row: any) => void }) { return ( } /> ); } diff --git a/src/screens/DailyLogsPage.tsx b/src/screens/DailyLogsPage.tsx index 908304b..18fcc5b 100644 --- a/src/screens/DailyLogsPage.tsx +++ b/src/screens/DailyLogsPage.tsx @@ -30,11 +30,7 @@ export function DailyLogsPage() { Punch In } - rowActions={(row) => ( - - )} + onPunchOutRow={(row) => setPunchOutInstanceId(row.instance_id as string | number)} /> } - rowActions={(row) => ( - - )} + onEditRow={(row) => setEditingInstanceId(row.instance_id as string | number)} />