order and calls are done,fixed detail view with those table for grid

This commit is contained in:
suryac 2026-07-07 17:38:41 +05:30
parent 94fbcc16ee
commit 785885c383
9 changed files with 369 additions and 51 deletions

View File

@ -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 (
<div className="flex flex-col gap-4">
{entries.map((entry, i) => (
<Card key={entry.id} title={i === 0 && title ? title : `Update on ${new Date(entry.created_at).toLocaleString()}`}>
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{Object.entries(entry.data).map(([key, value]) => (
<div key={key} className="flex flex-col gap-1 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(value)}
</dd>
</div>
))}
</dl>
</Card>
))}
{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 (
<Card
key={entry.id}
title={headerTitle}
action={<span className="text-xs font-medium text-faint">{updatedAt}</span>}
>
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{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<string, unknown>[];
// 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 (
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
<table className="min-w-full divide-y divide-border-subtle text-left bg-white">
<thead className="bg-slate-50">
<tr>
{headers.map(h => (
<th key={h} className="px-4 py-2 text-[10px] font-bold uppercase text-muted tracking-wide whitespace-nowrap">
{h.replace(/_/g, ' ')}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{rows.map((row, idx) => (
<tr key={idx} className="hover:bg-slate-50/50">
{headers.map(h => (
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
{formatValue(row[h])}
</td>
))}
</tr>
))}
</tbody>
</table>
</dd>
</div>
);
}
return (
<div key={key} className="flex flex-col gap-1 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
{key.replace(/_/g, ' ')}
</dt>
<dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(value)}
</dd>
</div>
);
})}
</dl>
</Card>
);
})}
</div>
);
}

View File

@ -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<number | string | undefined>(initialInstanceId);
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
@ -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<string | null>(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 <div className="p-8 flex justify-center"><Spinner label="Loading form..." /></div>;
}
@ -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 (
<GridInput
<SmartGridField
label={f.name}
columns={f.columns || []}
value={(val as Record<string, unknown>[]) || []}
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)}
/>
);
};

View File

@ -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<string, unknown>[];
onChange: (val: Record<string, unknown>[]) => 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 (
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
{value.length === 0 ? (
<span className="text-sm text-faint italic">No rows added.</span>
) : (
<div className="flex flex-col gap-4">
{value.map((row, i) => (
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
<div className="absolute top-2 right-2">
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
Remove
</button>
</div>
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{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 (
<Select
key={col.id}
label={col.name}
required={col.mandatory}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, e.target.value)}
options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
/>
);
}
return (
<Input
key={col.id}
label={col.name}
required={col.mandatory}
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
value={(val as string) ?? ''}
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
/>
);
})}
</div>
</div>
))}
</div>
)}
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
+ Add Row
</Button>
</div>
);
}

View File

@ -10,3 +10,4 @@ export * from './DateField';
export * from './TimeField';
export * from './WfLookupField';
export * from './RadioField';
export * from './SmartGridField';

View File

@ -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 && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
</div>
<button
onClick={onClose}
aria-label="Close"
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
>
<X size={18} />
</button>
<div className="flex items-center gap-2">
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
<button
onClick={onClose}
aria-label="Close"
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
>
<X size={18} />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
</div>

View File

@ -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={
<>
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
Potential Mining
</Button>
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
</>
}
>
{instanceId != null && <CallDetail instanceId={instanceId} />}
</Modal>
<Modal
open={activeActivity != null}
onClose={() => setActiveActivity(null)}
title={activeActivity?.name}
width="md"
>
{activeActivity && instanceId != null && (
<DynamicForm
client={orderBookingClient}
activityId={activeActivity.id}
instanceId={instanceId}
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
onSuccess={() => {
setActiveActivity(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
/>
)}
</Modal>
</>
);
}

View File

@ -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<number | string | null>(null);

View File

@ -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={
<>
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
Potential Mining
</Button>
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
Place Order
</Button>
</>
}
>
{instanceId != null && <OrderDetail instanceId={instanceId} />}
</Modal>
<Modal
open={activeActivity != null}
onClose={() => setActiveActivity(null)}
title={activeActivity?.name}
width="md"
>
{activeActivity && instanceId != null && (
<DynamicForm
client={orderBookingClient}
activityId={activeActivity.id}
instanceId={instanceId}
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
onSuccess={() => {
setActiveActivity(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => setActiveActivity(null)}
/>
)}
</Modal>
</>
);
}

View File

@ -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<number | string | null>(null);