diff --git a/src/components/dv/DetailView.tsx b/src/components/dv/DetailView.tsx
index bf66ce5..baf69a3 100644
--- a/src/components/dv/DetailView.tsx
+++ b/src/components/dv/DetailView.tsx
@@ -3,6 +3,7 @@ import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client';
import type { AuditEntry } from '../../api/types';
+import { WORKFLOWS } from '../../api/config';
import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
@@ -22,6 +23,23 @@ export interface DetailViewProps {
columns?: 1 | 2 | 3;
}
+/**
+ * Helper to resolve a human-readable activity name from its UID.
+ */
+function getActivityName(uid: string): string | undefined {
+ for (const wf of Object.values(WORKFLOWS)) {
+ for (const [actName, actDef] of Object.entries(wf.activities)) {
+ if (actDef.uid === uid) {
+ return actName
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ');
+ }
+ }
+ }
+ return undefined;
+}
+
/**
* Generic Zino detail view. Fetches `GET /app/{id}/view/audit` and
* renders the audit trail as a labeled definition grid.
@@ -81,22 +99,80 @@ export function DetailView({ client, instanceId, title, columns = 2 }: DetailVie
return (
- {entries.map((entry, i) => (
-
-
- {Object.entries(entry.data).map(([key, value]) => (
-
-
-
- {key.replace(/_/g, ' ')}
-
- -
- {formatValue(value)}
-
-
- ))}
-
-
- ))}
+ {entries.map((entry, i) => {
+ const actName = getActivityName(entry.activity_id);
+ const headerTitle = actName || (i === 0 && title ? title : 'Update');
+ const updatedAt = new Date(entry.created_at).toLocaleString();
+
+ return (
+
{updatedAt}}
+ >
+
+ {Object.entries(entry.data).map(([key, value]) => {
+ const isGridArray =
+ Array.isArray(value) &&
+ value.length > 0 &&
+ typeof value[0] === 'object' &&
+ value[0] !== null &&
+ !('original_name' in value[0]) &&
+ !('url' in value[0]);
+
+ if (isGridArray) {
+ const rows = value as Record[];
+ // Use all unique keys found across all rows just in case they differ slightly
+ const headers = Array.from(new Set(rows.flatMap(r => Object.keys(r))));
+
+ return (
+
+
-
+ {key.replace(/_/g, ' ')}
+
+
-
+
+
+
+ {headers.map(h => (
+ |
+ {h.replace(/_/g, ' ')}
+ |
+ ))}
+
+
+
+ {rows.map((row, idx) => (
+
+ {headers.map(h => (
+ |
+ {formatValue(row[h])}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+
-
+ {key.replace(/_/g, ' ')}
+
+ -
+ {formatValue(value)}
+
+
+ );
+ })}
+
+
+ );
+ })}
);
}
diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx
index 9509a9f..9c847e2 100644
--- a/src/components/forms/DynamicForm.tsx
+++ b/src/components/forms/DynamicForm.tsx
@@ -5,7 +5,7 @@ import { Button } from '../buttons/Button';
import { Spinner } from '../reusable/Spinner';
import {
FileInput,
- GridInput,
+ SmartGridField,
GeolocationInput,
PhoneInput,
SelectField,
@@ -24,9 +24,10 @@ export interface DynamicFormProps {
instanceId?: number | string;
onSuccess?: () => void;
onCancel?: () => void;
+ ignorePrefill?: boolean;
}
-export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel }: DynamicFormProps) {
+export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill }: DynamicFormProps) {
const [currentActivityId, setCurrentActivityId] = useState(initialActivityId);
const [currentInstanceId, setCurrentInstanceId] = useState(initialInstanceId);
const [chainQueue, setChainQueue] = useState>([]);
@@ -71,15 +72,18 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id)
);
- if (res.prefill_data) {
- Object.entries(res.prefill_data).forEach(([k, v]) => {
- if (!imageFieldIds.has(k)) defaultValues[k] = v;
- });
- } else if (res.data) {
- Object.entries(res.data).forEach(([k, v]) => {
- if (!imageFieldIds.has(k)) defaultValues[k] = v;
- });
+ if (!ignorePrefill) {
+ if (res.prefill_data) {
+ Object.entries(res.prefill_data).forEach(([k, v]) => {
+ if (!imageFieldIds.has(k)) defaultValues[k] = v;
+ });
+ } else if (res.data) {
+ Object.entries(res.data).forEach(([k, v]) => {
+ if (!imageFieldIds.has(k)) defaultValues[k] = v;
+ });
+ }
}
+
setValues(defaultValues);
setLoading(false);
}
@@ -97,6 +101,26 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
+ const handleFieldChange = (fieldId: string, newVal: unknown) => {
+ setValues(prev => {
+ const next = { ...prev, [fieldId]: newVal };
+
+ // Auto-calculate order_details totals
+ if (fieldId === 'order_details' && Array.isArray(newVal)) {
+ let totalBags = 0;
+ let totalKgs = 0;
+ newVal.forEach(row => {
+ totalBags += Number(row.bags) || 0;
+ totalKgs += Number(row.row_kgs) || 0;
+ });
+ next['total_bags'] = totalBags;
+ next['total_kgs'] = totalKgs;
+ }
+
+ return next;
+ });
+ };
+
if (loading) {
return
;
}
@@ -193,7 +217,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
activityId={currentActivityId}
fieldId={f.id}
formData={values}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -205,7 +229,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -217,7 +241,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -229,18 +253,18 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
type={type}
required={f.mandatory}
value={val}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('grid')) {
return (
- []) || []}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -251,7 +275,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={(val as { latitude: number; longitude: number }) || null}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -263,7 +287,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={phoneStr}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -274,7 +298,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -285,7 +309,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -296,7 +320,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -307,7 +331,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
@@ -318,7 +342,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
required={f.mandatory}
type={type}
value={(val as string) ?? ''}
- onChange={(newVal) => setValues(prev => ({ ...prev, [f.id]: newVal }))}
+ onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
};
diff --git a/src/components/forms/fields/SmartGridField.tsx b/src/components/forms/fields/SmartGridField.tsx
new file mode 100644
index 0000000..8c45803
--- /dev/null
+++ b/src/components/forms/fields/SmartGridField.tsx
@@ -0,0 +1,145 @@
+import { Button } from '../../buttons/Button';
+import { Select } from '../../reusable/Select';
+import { Input } from '../../reusable/Input';
+import type { FormScreenField } from '../../../api/types';
+
+export function SmartGridField({
+ label,
+ columns,
+ value = [],
+ onChange,
+}: {
+ label: string;
+ columns: FormScreenField[];
+ value: Record[];
+ onChange: (val: Record[]) => void;
+}) {
+ const addRow = () => onChange([...value, {}]);
+
+ const removeRow = (idx: number) => {
+ const next = [...value];
+ next.splice(idx, 1);
+ onChange(next);
+ };
+
+ const updateRow = (idx: number, fieldId: string, val: unknown) => {
+ const next = [...value];
+ let row = { ...next[idx], [fieldId]: val };
+
+ const colDef = columns.find(c => c.id === fieldId);
+
+ // If product category changes, clear out the rest of the row's data
+ if (colDef && (colDef.id === 'product_category' || colDef.name === 'Product Category')) {
+ Object.keys(row).forEach(k => {
+ if (k !== fieldId) {
+ row[k] = '';
+ }
+ });
+ }
+
+ // Auto-fill dataset keys from _raw
+ if (colDef && (colDef.data_type === 'select' || colDef.data_type === 'multiselect')) {
+ const option = colDef.properties?.options?.find(o => String(o.value) === String(val));
+ if (option && option._raw) {
+ for (const key of Object.keys(option._raw)) {
+ // Match column ID with or without underscores (e.g., brcode <-> br_code)
+ const targetCol = columns.find(c => c.id === key || c.id.replace(/_/g, '') === key.replace(/_/g, ''));
+ if (targetCol && targetCol.id !== fieldId) {
+ row[targetCol.id] = option._raw[key];
+ }
+ }
+ }
+ }
+
+ // Auto-calculate row_kgs if sku and bags are present
+ const skuVal = Number(row.sku) || 0;
+ const bagsVal = Number(row.bags) || 0;
+ row.row_kgs = skuVal * bagsVal;
+
+ next[idx] = row;
+ onChange(next);
+ };
+
+ return (
+
+
{label}
+ {value.length === 0 ? (
+
No rows added.
+ ) : (
+
+ {value.map((row, i) => (
+
+
+
+
+
Row {i + 1}
+
+ {columns.map(col => {
+ const val = row[col.id];
+ if (col.data_type === 'select' || col.data_type === 'multiselect') {
+ // Cascade filter options
+ const allOpts = col.properties?.options || [];
+ let filteredOpts = allOpts;
+
+ // Specific logic for product_name depending on product_category
+ if (col.id === 'product_name' || col.name === 'Product Name') {
+ const catCol = columns.find(c => c.id === 'product_category' || c.name === 'Product Category');
+ if (catCol) {
+ const selectedCategory = row[catCol.id] as string;
+ if (selectedCategory) {
+ filteredOpts = allOpts.filter(opt => {
+ const labelStr = String(opt.label || opt.value || '');
+ return labelStr.startsWith(selectedCategory);
+ });
+ }
+ }
+ } else {
+ // Generic _raw cascading for other fields just in case
+ filteredOpts = allOpts.filter(opt => {
+ if (!opt._raw) return true;
+ for (const [rowKey, rowVal] of Object.entries(row)) {
+ if (rowKey === col.id || rowVal == null || rowVal === '') continue;
+ const rawKey = Object.keys(opt._raw).find(rk => rk === rowKey || rk.replace(/_/g, '') === rowKey.replace(/_/g, ''));
+ if (rawKey && String(opt._raw[rawKey]) !== String(rowVal)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ return (
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/forms/fields/index.ts b/src/components/forms/fields/index.ts
index 37dd11a..3424ba8 100644
--- a/src/components/forms/fields/index.ts
+++ b/src/components/forms/fields/index.ts
@@ -10,3 +10,4 @@ export * from './DateField';
export * from './TimeField';
export * from './WfLookupField';
export * from './RadioField';
+export * from './SmartGridField';
diff --git a/src/components/reusable/Modal.tsx b/src/components/reusable/Modal.tsx
index 00b34c5..abdda9e 100644
--- a/src/components/reusable/Modal.tsx
+++ b/src/components/reusable/Modal.tsx
@@ -19,11 +19,12 @@ export interface ModalProps {
subtitle?: ReactNode;
/** @default "md" */
width?: ModalWidth;
+ actions?: ReactNode;
children?: ReactNode;
}
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
-export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
+export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -61,13 +62,16 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', children }
{title && {title}
}
{subtitle && {subtitle}
}
-
+
+ {actions &&
{actions}
}
+
+
{children}
diff --git a/src/screens/CallsPage.tsx b/src/screens/CallsPage.tsx
index 24b254a..0bd6938 100644
--- a/src/screens/CallsPage.tsx
+++ b/src/screens/CallsPage.tsx
@@ -10,10 +10,12 @@ import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
export function CallsPage() {
- const { instanceId } = useParams();
+ const params = useParams();
+ const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
+ const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
return (
<>
@@ -52,9 +54,40 @@ export function CallsPage() {
onClose={() => navigate(`/calls`)}
title={instanceId != null ? `Call #${instanceId}` : undefined}
width="lg"
+ actions={
+ <>
+
+
+ >
+ }
>
{instanceId != null && }
+
+ setActiveActivity(null)}
+ title={activeActivity?.name}
+ width="md"
+ >
+ {activeActivity && instanceId != null && (
+ {
+ setActiveActivity(null);
+ setRefreshKey(k => k + 1);
+ }}
+ onCancel={() => setActiveActivity(null)}
+ />
+ )}
+
>
);
}
diff --git a/src/screens/DailyLogsPage.tsx b/src/screens/DailyLogsPage.tsx
index 62c2c48..908304b 100644
--- a/src/screens/DailyLogsPage.tsx
+++ b/src/screens/DailyLogsPage.tsx
@@ -10,7 +10,8 @@ import { DAILY_REPORTS } from '../api/config';
import { dailyReportsClient } from '../api/clients';
export function DailyLogsPage() {
- const { instanceId } = useParams();
+ const params = useParams();
+ const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [punchOutInstanceId, setPunchOutInstanceId] = useState(null);
diff --git a/src/screens/OrdersPage.tsx b/src/screens/OrdersPage.tsx
index 1db86dd..3f85b3a 100644
--- a/src/screens/OrdersPage.tsx
+++ b/src/screens/OrdersPage.tsx
@@ -10,10 +10,12 @@ import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
export function OrdersPage() {
- const { instanceId } = useParams();
+ const params = useParams();
+ const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
+ const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
return (
<>
@@ -52,9 +54,40 @@ export function OrdersPage() {
onClose={() => navigate(`/orders`)}
title={instanceId != null ? `Order #${instanceId}` : undefined}
width="lg"
+ actions={
+ <>
+
+
+ >
+ }
>
{instanceId != null && }
+
+ setActiveActivity(null)}
+ title={activeActivity?.name}
+ width="md"
+ >
+ {activeActivity && instanceId != null && (
+ {
+ setActiveActivity(null);
+ setRefreshKey(k => k + 1);
+ }}
+ onCancel={() => setActiveActivity(null)}
+ />
+ )}
+
>
);
}
diff --git a/src/screens/StoresPage.tsx b/src/screens/StoresPage.tsx
index 8bc799a..dd9e1cc 100644
--- a/src/screens/StoresPage.tsx
+++ b/src/screens/StoresPage.tsx
@@ -10,7 +10,8 @@ import { STORE } from '../api/config';
import { storeClient } from '../api/clients';
export function StoresPage() {
- const { instanceId } = useParams();
+ const params = useParams();
+ const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [editingInstanceId, setEditingInstanceId] = useState(null);