diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2d17ec1 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Local development only (`npm run dev`). Reference for the values +# `npm run dev` reads from .env; nothing here is used by a deployed build. +# +# In a deployed environment these values come from config.js, which the server +# writes when it places the build. Do not commit environment-specific values — +# one build is promoted across environments unchanged. +VITE_ZINO_API_URL=http://localhost:8085 +# The app this frontend serves. With it set, the SDK sends workflow and view +# calls to /app//... on the API host; sign-in stays at /usr/login. +VITE_ZINO_APP_ID= +VITE_ZINO_ORG_ID= +VITE_ZINO_MOCK=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b08070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.local diff --git a/README.md b/README.md deleted file mode 100644 index 13a380b..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# truck-desk - -Truck Desk — generated by frontgen \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..1cb73a0 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + Truck Desk + + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c8d3e4 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "truck-desk", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc -b", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.60.5", + "lucide-react": "^0.441.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "~5.5.4", + "vite": "^5.4.2" + } +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..9edae0c --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,11 @@ +// Replace this file with the real app: routes, pages and layout live here. +export default function App() { + return ( +
+
+

Truck Desk

+

Nothing has been built here yet.

+
+
+ ) +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..87ddc20 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,9 @@ +// Workflow, step and view ids this frontend uses. Keep every id here and +// import it where it is needed; never write an id inline in a page. +// +// Examples: +// export const WORKFLOW_IDS = { TRUCK_ONBOARDING: 'truck-onboarding' } as const +// export const ACTIVITY_IDS = { START: 'start-registration' } as const +// export const RECORD_VIEW_IDS = { TRUCKS: 'rv-trucks' } as const +// export const DETAIL_VIEW_IDS = { TRUCK: 'dv-truck' } as const +export {} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..733ed28 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { ZinoProvider } from './zino-sdk' +import { requireConfigValue, resolveConfigValue, basePath } from './runtimeConfig' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/src/runtimeConfig.ts b/src/runtimeConfig.ts new file mode 100644 index 0000000..df0b705 --- /dev/null +++ b/src/runtimeConfig.ts @@ -0,0 +1,35 @@ +declare global { + interface Window { + __RUNTIME_CONFIG__?: Record + } +} + +// SAME_ORIGIN resolves to the serving origin, for a frontend mounted on a custom +// domain where the platform is proxied under the same host. It exists because an +// empty string is falsy and so can never survive the || chain below, which is +// exactly what a "use the current origin" value has to do. +export function resolveConfigValue(key: string, fallback = ''): string { + const runtime = (window.__RUNTIME_CONFIG__ || {})[key] + const devFallback = import.meta.env.DEV ? (import.meta.env[key] as string) : '' + const value = runtime || devFallback || fallback + return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value +} + +// Throws rather than silently running against the wrong backend. +export function requireConfigValue(key: string): string { + const value = resolveConfigValue(key) + if (!value) { + throw new Error( + 'Missing runtime config "' + key + '". The server writes config.js when it ' + + 'places the build; for local dev set ' + key + ' in .env.', + ) + } + return value +} + +// The mount path this app is served under, taken from the the server +// writes into index.html. Drives the router basename, so one build serves any +// mount without being rebuilt. +export function basePath(): string { + return new URL(document.baseURI).pathname +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/zino-sdk/DynamicForm.tsx b/src/zino-sdk/DynamicForm.tsx new file mode 100644 index 0000000..9ef2870 --- /dev/null +++ b/src/zino-sdk/DynamicForm.tsx @@ -0,0 +1,175 @@ +import React, { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useZino } from "./provider"; +import type { FormField } from "./types"; + +export interface DynamicFormProps { + /** Workflow definition ID. */ + workflowId: string; + /** Activity UID — the form schema is fetched for this activity. */ + activityId: string; + /** Instance ID — omit for INIT activities (start workflow). */ + instanceId?: string; + /** Called after successful submission with the result. */ + onSuccess?: (result: unknown) => void; + /** Called on submission error. */ + onError?: (error: unknown) => void; + /** Optional CSS class for the form wrapper. */ + className?: string; +} + +/** + * Renders a dynamic form based on the activity's form schema. + * + * Fetches the form config from the core-service, renders fields by type, + * and submits via performActivity or startWorkflow. + * + * ```tsx + * { closeModal(); }} + * /> + * ``` + */ +export function DynamicForm({ + workflowId, + activityId, + instanceId, + onSuccess, + onError, + className, +}: DynamicFormProps) { + const { workflows } = useZino(); + const queryClient = useQueryClient(); + const [formData, setFormData] = useState>({}); + + // Fetch form schema + const { data: form, isLoading: formLoading, error: formError } = useQuery({ + queryKey: ["form", workflowId, activityId, instanceId], + queryFn: () => workflows.getForm(workflowId, activityId, "desktop", instanceId), + }); + + // Submit mutation + const submit = useMutation({ + mutationFn: async (data: Record) => { + if (instanceId) { + return workflows.performActivity(workflowId, instanceId, activityId, data); + } else { + return workflows.startWorkflow(workflowId, activityId, data); + } + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["recordview"] }); + queryClient.invalidateQueries({ queryKey: ["instance"] }); + queryClient.invalidateQueries({ queryKey: ["detailview"] }); + queryClient.invalidateQueries({ queryKey: ["audit"] }); + onSuccess?.(result); + }, + onError: (err) => { + onError?.(err); + }, + }); + + const handleChange = (fieldId: string, value: string) => { + setFormData((prev) => ({ ...prev, [fieldId]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // Map form field IDs to their mapped_activity_field_id + const mapped: Record = {}; + for (const field of form?.form_json?.fields ?? []) { + const key = field.mapped_activity_field_id || field.id; + mapped[key] = formData[field.id] ?? ""; + } + submit.mutate(mapped); + }; + + if (formLoading) { + return
; + } + + if (formError) { + return
Failed to load form: {(formError as any)?.message ?? "Unknown error"}
; + } + + const fields: FormField[] = form?.form_json?.fields ?? []; + + return ( +
+ {form?.form_json?.title && ( +

{form.form_json.title}

+ )} + +
+ {fields.map((field) => ( +
+ + {renderField(field, formData[field.id] ?? "", (v) => handleChange(field.id, v))} +
+ ))} +
+ + {submit.error && ( +
+ {(submit.error as any)?.message ?? "Submission failed"} +
+ )} + + +
+ ); +} + +function renderField( + field: FormField, + value: string, + onChange: (value: string) => void, +) { + const baseClass = + "w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"; + + switch (field.type) { + case "paragraph": + return ( +