feat:form fields stored in localstorage

This commit is contained in:
suryac 2026-10-01 16:34:38 +05:30
parent 40518cbea9
commit 7495ae8887
8 changed files with 199 additions and 53 deletions

25
package-lock.json generated
View File

@ -13,6 +13,7 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localforage": "^1.10.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
@ -2521,6 +2522,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": { "node_modules/immer": {
"version": "11.1.11", "version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
@ -2720,6 +2727,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -2981,6 +2997,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@ -15,6 +15,7 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8", "jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localforage": "^1.10.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",

View File

@ -5,6 +5,8 @@
// docs under src/docs/api. // docs under src/docs/api.
export const APP_ID = '434'; export const APP_ID = '434';
export const APP_VERSION = '1.0.1';
export const FORM_DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000;
// Backend URL comes from the runtime config.js the frontgen server writes at // Backend URL comes from the runtime config.js the frontgen server writes at
// placement time (window.__RUNTIME_CONFIG__.VITE_ZINO_API_URL) so one built // placement time (window.__RUNTIME_CONFIG__.VITE_ZINO_API_URL) so one built

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import localforage from 'localforage';
import type { ZinoClient } from '../../api/client'; import type { ZinoClient } from '../../api/client';
import type { FormScreenResponse, FormScreenField } from '../../api/types'; import type { FormScreenResponse, FormScreenField } from '../../api/types';
import { Button } from '../buttons/Button'; import { Button } from '../buttons/Button';
@ -17,7 +18,7 @@ import {
WfLookupField, WfLookupField,
RadioField, RadioField,
} from './fields'; } from './fields';
import { ORDER_BOOKING, STORE, DAILY_REPORTS, PIPELINE, HIDDEN_FORM_FIELDS } from '../../api/config'; import { ORDER_BOOKING, STORE, DAILY_REPORTS, PIPELINE, HIDDEN_FORM_FIELDS, FORM_DRAFT_EXPIRY_MS, APP_VERSION } from '../../api/config';
import { isCategoryColumn, isProductColumn } from './fields/gridUtils'; import { isCategoryColumn, isProductColumn } from './fields/gridUtils';
export interface DynamicFormProps { export interface DynamicFormProps {
@ -166,6 +167,23 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
} }
} }
defaultValuesRef.current = { ...defaultValues };
try {
const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`;
const draft: any = await localforage.getItem(draftKey);
if (draft && draft.version === APP_VERSION && draft.values) {
const now = Date.now();
if (now - draft.timestamp < FORM_DRAFT_EXPIRY_MS) {
Object.assign(defaultValues, draft.values);
} else {
await localforage.removeItem(draftKey);
}
}
} catch (err) {
console.warn('Failed to load draft form', err);
}
setValues(defaultValues); setValues(defaultValues);
setLoading(false); setLoading(false);
if (res.activity_name) { if (res.activity_name) {
@ -182,14 +200,34 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
return () => { mounted = false; }; return () => { mounted = false; };
}, [client, currentActivityId, currentInstanceId]); }, [client, currentActivityId, currentInstanceId]);
const defaultValuesRef = useRef<Record<string, unknown>>({});
const [values, setValues] = useState<Record<string, unknown>>({}); const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [showErrors, setShowErrors] = useState(false); const [showErrors, setShowErrors] = useState(false);
const handleReset = async () => {
if (window.confirm('Are you sure you want to clear the draft and reset this form?')) {
const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`;
await localforage.removeItem(draftKey);
setValues({ ...defaultValuesRef.current });
}
};
const clickedActionRef = useRef<string | null>(null); const clickedActionRef = useRef<string | null>(null);
useEffect(() => {
if (Object.keys(values).length > 0 && !hasSubmitted && !loading) {
const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`;
localforage.setItem(draftKey, {
version: APP_VERSION,
timestamp: Date.now(),
values
}).catch(e => console.warn('Failed to save draft form', e));
}
}, [values, currentActivityId, currentInstanceId, hasSubmitted, loading]);
const handleFieldChange = (fieldId: string, newVal: unknown, fullRow?: any) => { const handleFieldChange = (fieldId: string, newVal: unknown, fullRow?: any) => {
if (submitError) { if (submitError) {
setSubmitError(null); setSubmitError(null);
@ -562,20 +600,26 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
phone: phoneNum, phone: phoneNum,
phone_with_dial_code: `+91${phoneNum}` phone_with_dial_code: `+91${phoneNum}`
}; };
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val)) { } else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && (val[0] instanceof File || val[0] instanceof Blob)) {
const uploadedFiles = []; const uploadedFiles = [];
for (const file of val) { for (const file of val) {
if (file instanceof File) { let fileToUpload = file as File;
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); if (!(fileToUpload instanceof File)) {
uploadedFiles.push(fileMeta); fileToUpload = new File([file], (file as any).name || "image.jpg", { type: file.type || 'image/jpeg' });
} else {
uploadedFiles.push(file);
} }
const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
uploadedFiles.push(fileMeta);
} }
payload[f.id] = uploadedFiles; payload[f.id] = uploadedFiles;
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) { } else if ((f.data_type === 'image' || f.data_type === 'file') && (val instanceof File || val instanceof Blob)) {
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); let fileToUpload = val as File;
if (!(fileToUpload instanceof File)) {
fileToUpload = new File([val], (val as any).name || "image.jpg", { type: val.type || 'image/jpeg' });
}
const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId });
payload[f.id] = [fileMeta]; payload[f.id] = [fileMeta];
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val)) {
payload[f.id] = val;
} else if ((f.data_type === 'image' || f.data_type === 'file') && val && typeof val === 'object') { } else if ((f.data_type === 'image' || f.data_type === 'file') && val && typeof val === 'object') {
payload[f.id] = [val]; payload[f.id] = [val];
} else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') { } else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') {
@ -713,6 +757,8 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
} }
setHasSubmitted(true); setHasSubmitted(true);
const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`;
localforage.removeItem(draftKey).catch(() => {});
let pending = [...chainQueue]; let pending = [...chainQueue];
let chainSource = (res as any).activity_chain || schema.activity_chain || []; let chainSource = (res as any).activity_chain || schema.activity_chain || [];
@ -1035,35 +1081,42 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>} {submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
{onCancel && ( <Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}>
<Button type="button" variant="secondary" onClick={() => { Reset Draft
if (hasSubmitted) { </Button>
onSuccess?.(); <div className="flex items-center gap-3">
} else { {onCancel && (
onCancel(); <Button type="button" variant="secondary" onClick={() => {
} if (hasSubmitted) {
}} disabled={submitting}> onSuccess?.();
Cancel } else {
</Button> const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`;
)} localforage.removeItem(draftKey).catch(() => {});
{actionField && actionField.properties?.options ? ( onCancel();
actionField.properties.options.map((opt: any) => ( }
<Button }} disabled={submitting}>
key={opt.value} Cancel
type="submit"
variant="primary"
disabled={submitting}
onClick={() => { clickedActionRef.current = opt.value; }}
>
{submitting && clickedActionRef.current === opt.value ? 'Submitting...' : opt.label}
</Button> </Button>
)) )}
) : ( {actionField && actionField.properties?.options ? (
<Button type="submit" variant="primary" disabled={submitting}> actionField.properties.options.map((opt: any) => (
{submitting ? 'Submitting...' : 'Submit'} <Button
</Button> key={opt.value}
)} type="submit"
variant="primary"
disabled={submitting}
onClick={() => { clickedActionRef.current = opt.value; }}
>
{submitting && clickedActionRef.current === opt.value ? 'Submitting...' : opt.label}
</Button>
))
) : (
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</Button>
)}
</div>
</div> </div>
</form> </form>
); );

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import localforage from 'localforage';
import type { ZinoClient } from '../../api/client'; import type { ZinoClient } from '../../api/client';
import type { FormScreenResponse } from '../../api/types'; import type { FormScreenResponse } from '../../api/types';
import { ORDER_BOOKING, LOOKUP_RECORD_LIMIT } from '../../api/config'; import { ORDER_BOOKING, LOOKUP_RECORD_LIMIT, APP_VERSION, FORM_DRAFT_EXPIRY_MS } from '../../api/config';
import { Button } from '../buttons/Button'; import { Button } from '../buttons/Button';
import { Select } from '../reusable/Select'; import { Select } from '../reusable/Select';
import { DateField, TimeField, FileInput, TextField } from './fields'; import { DateField, TimeField, FileInput, TextField } from './fields';
@ -58,7 +59,15 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const valuesRef = useRef(values); const valuesRef = useRef(values);
useEffect(() => { useEffect(() => {
valuesRef.current = values; valuesRef.current = values;
}, [values]); if (Object.keys(values).length > 0) {
const draftKey = `draft_form_${activityId}_new`;
localforage.setItem(draftKey, {
version: APP_VERSION,
timestamp: Date.now(),
values
}).catch(e => console.warn('Failed to save draft form', e));
}
}, [values, activityId]);
const [storeOptions, setStoreOptions] = useState<{ value: string; label: string; _raw?: any }[]>([]); const [storeOptions, setStoreOptions] = useState<{ value: string; label: string; _raw?: any }[]>([]);
const [storeSearchQuery, setStoreSearchQuery] = useState(''); const [storeSearchQuery, setStoreSearchQuery] = useState('');
@ -119,6 +128,21 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
Object.assign(initialValues, res.data); Object.assign(initialValues, res.data);
} }
try {
const draftKey = `draft_form_${activityId}_new`;
const draft: any = await localforage.getItem(draftKey);
if (draft && draft.version === APP_VERSION && draft.values) {
const now = Date.now();
if (now - draft.timestamp < FORM_DRAFT_EXPIRY_MS) {
Object.assign(initialValues, draft.values);
} else {
await localforage.removeItem(draftKey);
}
}
} catch (e) {
console.warn('Failed to load draft form for Log Visit', e);
}
setValues(prev => { setValues(prev => {
const next = { ...initialValues, ...prev }; const next = { ...initialValues, ...prev };
valuesRef.current = next; valuesRef.current = next;
@ -289,6 +313,22 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
fetchStoreOptions(); fetchStoreOptions();
}; };
const handleReset = async () => {
if (window.confirm('Are you sure you want to clear the draft and reset this form?')) {
const draftKey = `draft_form_${activityId}_new`;
await localforage.removeItem(draftKey);
const resetValues = {
date_of_visit: todayStr,
time_of_visit: nowTimeStr,
};
setValues(resetValues);
valuesRef.current = resetValues;
setStoreOptions([]);
}
};
const handleStoreSelect = (fieldId: string, val: string) => { const handleStoreSelect = (fieldId: string, val: string) => {
const selectedOpt = storeOptions.find(o => String(o.value) === String(val)); const selectedOpt = storeOptions.find(o => String(o.value) === String(val));
const rawRow = selectedOpt?._raw || {}; const rawRow = selectedOpt?._raw || {};
@ -334,11 +374,15 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
// Handle image upload if present // Handle image upload if present
let uploadedFiles: any[] = []; let uploadedFiles: any[] = [];
const imgVal = valuesRef.current.upload_image; const imgVal = valuesRef.current.upload_image;
const fileList = Array.isArray(imgVal) ? imgVal : (imgVal instanceof File ? [imgVal] : []); const fileList = Array.isArray(imgVal) ? imgVal : (imgVal instanceof File || imgVal instanceof Blob ? [imgVal] : []);
for (const fileItem of fileList) { for (const fileItem of fileList) {
if (fileItem instanceof File) { if (fileItem instanceof File || fileItem instanceof Blob) {
const fileMeta = await client.uploadFile(fileItem, { let fileToUpload = fileItem as File;
if (!(fileToUpload instanceof File)) {
fileToUpload = new File([fileItem], (fileItem as any).name || "image.jpg", { type: fileItem.type || 'image/jpeg' });
}
const fileMeta = await client.uploadFile(fileToUpload, {
activityId, activityId,
fieldId: 'upload_image', fieldId: 'upload_image',
}); });
@ -352,6 +396,9 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
const res: any = await client.startInstance(activityId, payload); const res: any = await client.startInstance(activityId, payload);
const draftKey = `draft_form_${activityId}_new`;
localforage.removeItem(draftKey).catch(() => {});
const chainSource = res?.activity_chain || schema?.activity_chain || []; const chainSource = res?.activity_chain || schema?.activity_chain || [];
if (chainSource && chainSource.length > 0) { if (chainSource && chainSource.length > 0) {
@ -530,15 +577,24 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }:
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>} {submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle"> <div className="flex items-center justify-between mt-4 pt-4 border-t border-border-subtle">
{onCancel && ( <Button type="button" variant="ghost" className="text-ruby-600 px-2" onClick={handleReset} disabled={submitting}>
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}> Reset Draft
Cancel
</Button>
)}
<Button type="submit" disabled={Boolean(submitting || fetchingDailyLog)}>
{submitting ? 'Submitting...' : 'Log Visit'}
</Button> </Button>
<div className="flex items-center gap-3">
{onCancel && (
<Button type="button" variant="secondary" onClick={() => {
const draftKey = `draft_form_${activityId}_new`;
localforage.removeItem(draftKey).catch(() => {});
onCancel();
}} disabled={submitting}>
Cancel
</Button>
)}
<Button type="submit" disabled={Boolean(submitting || fetchingDailyLog)}>
{submitting ? 'Submitting...' : 'Log Visit'}
</Button>
</div>
</div> </div>
</form> </form>
); );

View File

@ -1,16 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { BASE_URL, APP_ID } from '../../../api/config'; import { BASE_URL, APP_ID } from '../../../api/config';
export function ImagePreview({ file }: { file: File | any }) { export function ImagePreview({ file }: { file: File | Blob | any }) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (file instanceof File) { if (file instanceof File || file instanceof Blob) {
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl); setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl); return () => URL.revokeObjectURL(objectUrl);
} else if (file && file.uuid) { } else if (file && file.uuid) {
setUrl(`${BASE_URL}/app/${APP_ID}/view/files/${file.uuid}/preview`); setUrl(`${BASE_URL}/app/${APP_ID}/view/files/${file.uuid}/preview`);
} else if (typeof file === 'string' && file.startsWith('http')) {
setUrl(file);
} }
}, [file]); }, [file]);

View File

@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './styles/styles.css' import './styles/styles.css'
import App from './App.tsx' import App from './App.tsx'
import { APP_VERSION } from './api/config.ts'
console.log(`Krishna Sales App Version: ${APP_VERSION}`);
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@ -4,6 +4,7 @@ import { LogOut, ChevronDown, Database, Users } from 'lucide-react';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { APP_VERSION } from '../api/config';
@ -154,7 +155,7 @@ export function ConsoleLayout() {
</NavLink> </NavLink>
</div> </div>
)} )}
<div className="px-2"> <div className="px-2 pb-2 flex flex-col gap-2">
<button <button
onClick={() => { onClick={() => {
logout(); logout();
@ -165,6 +166,9 @@ export function ConsoleLayout() {
<LogOut size={16} /> <LogOut size={16} />
Sign out Sign out
</button> </button>
<div className="px-3 pt-2 border-t border-gray-100">
<span className="text-xs font-semibold text-gray-500 tracking-wide">Version {APP_VERSION}</span>
</div>
</div> </div>
</div> </div>
</div> </div>