diff --git a/package-lock.json b/package-lock.json index 62e7f18..d490d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", "leaflet": "^1.9.4", + "localforage": "^1.10.0", "lucide-react": "^0.400.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -2521,6 +2522,12 @@ "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": { "version": "11.1.11", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", @@ -2720,6 +2727,15 @@ "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2981,6 +2997,15 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/package.json b/package.json index b939f34..810a367 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", "leaflet": "^1.9.4", + "localforage": "^1.10.0", "lucide-react": "^0.400.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/src/api/config.ts b/src/api/config.ts index b6255b3..898bd12 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -5,6 +5,8 @@ // docs under src/docs/api. 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 // placement time (window.__RUNTIME_CONFIG__.VITE_ZINO_API_URL) so one built diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 5c8a88a..e7bc373 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import localforage from 'localforage'; import type { ZinoClient } from '../../api/client'; import type { FormScreenResponse, FormScreenField } from '../../api/types'; import { Button } from '../buttons/Button'; @@ -17,7 +18,7 @@ import { WfLookupField, RadioField, } 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'; 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); setLoading(false); if (res.activity_name) { @@ -182,14 +200,34 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: return () => { mounted = false; }; }, [client, currentActivityId, currentInstanceId]); + const defaultValuesRef = useRef>({}); const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false); const [submitError, setSubmitError] = useState(null); 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(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) => { if (submitError) { setSubmitError(null); @@ -562,20 +600,26 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: phone: 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 = []; for (const file of val) { - if (file instanceof File) { - const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); - uploadedFiles.push(fileMeta); - } else { - uploadedFiles.push(file); + let fileToUpload = file as File; + if (!(fileToUpload instanceof File)) { + fileToUpload = new File([file], (file as any).name || "image.jpg", { type: file.type || 'image/jpeg' }); } + const fileMeta = await client.uploadFile(fileToUpload, { activityId: currentActivityId, fieldId: f.id, instanceId: currentInstanceId }); + uploadedFiles.push(fileMeta); } payload[f.id] = uploadedFiles; - } 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 }); + } else if ((f.data_type === 'image' || f.data_type === 'file') && (val instanceof File || val instanceof Blob)) { + 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]; + } 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') { payload[f.id] = [val]; } 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); + const draftKey = `draft_form_${currentActivityId}_${currentInstanceId || 'new'}`; + localforage.removeItem(draftKey).catch(() => {}); let pending = [...chainQueue]; let chainSource = (res as any).activity_chain || schema.activity_chain || []; @@ -1035,35 +1081,42 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId: {submitError &&
{submitError}
} -
- {onCancel && ( - - )} - {actionField && actionField.properties?.options ? ( - actionField.properties.options.map((opt: any) => ( - +
+ {onCancel && ( + - )) - ) : ( - - )} + )} + {actionField && actionField.properties?.options ? ( + actionField.properties.options.map((opt: any) => ( + + )) + ) : ( + + )} +
); diff --git a/src/components/forms/LogVisitForm.tsx b/src/components/forms/LogVisitForm.tsx index 016d85c..87baa5b 100644 --- a/src/components/forms/LogVisitForm.tsx +++ b/src/components/forms/LogVisitForm.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; +import localforage from 'localforage'; import type { ZinoClient } from '../../api/client'; 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 { Select } from '../reusable/Select'; import { DateField, TimeField, FileInput, TextField } from './fields'; @@ -58,7 +59,15 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: const valuesRef = useRef(values); useEffect(() => { 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 [storeSearchQuery, setStoreSearchQuery] = useState(''); @@ -119,6 +128,21 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: 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 => { const next = { ...initialValues, ...prev }; valuesRef.current = next; @@ -289,6 +313,22 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: 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 selectedOpt = storeOptions.find(o => String(o.value) === String(val)); const rawRow = selectedOpt?._raw || {}; @@ -334,11 +374,15 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: // Handle image upload if present let uploadedFiles: any[] = []; 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) { - if (fileItem instanceof File) { - const fileMeta = await client.uploadFile(fileItem, { + if (fileItem instanceof File || fileItem instanceof Blob) { + 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, fieldId: 'upload_image', }); @@ -352,6 +396,9 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: 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 || []; if (chainSource && chainSource.length > 0) { @@ -530,15 +577,24 @@ export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: {submitError &&
{submitError}
} -
- {onCancel && ( - - )} - +
+ {onCancel && ( + + )} + +
); diff --git a/src/components/forms/fields/FileInput.tsx b/src/components/forms/fields/FileInput.tsx index c8627b7..410a6f3 100644 --- a/src/components/forms/fields/FileInput.tsx +++ b/src/components/forms/fields/FileInput.tsx @@ -1,16 +1,18 @@ import { useState, useEffect } from 'react'; 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(null); useEffect(() => { - if (file instanceof File) { + if (file instanceof File || file instanceof Blob) { const objectUrl = URL.createObjectURL(file); setUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); } else if (file && file.uuid) { setUrl(`${BASE_URL}/app/${APP_ID}/view/files/${file.uuid}/preview`); + } else if (typeof file === 'string' && file.startsWith('http')) { + setUrl(file); } }, [file]); diff --git a/src/main.tsx b/src/main.tsx index 975e6b7..dd123ca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './styles/styles.css' 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( diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index 17cdabc..0979868 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -4,6 +4,7 @@ import { LogOut, ChevronDown, Database, Users } from 'lucide-react'; import { cn } from '../lib/cn'; import { useAuth } from '../auth/context'; import { onAuthErrorAll, orderBookingClient } from '../api/clients'; +import { APP_VERSION } from '../api/config'; @@ -154,7 +155,7 @@ export function ConsoleLayout() { )} -
+
+
+ Version {APP_VERSION} +