First version: sign-in, home, list pages, record details, step forms

This commit is contained in:
Yashas C 2026-11-05 06:27:06 +00:00 committed by Zino Studio
parent 7a0abc5416
commit e60fcc8af3
10 changed files with 3348 additions and 16 deletions

2767
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,29 @@
// Replace this file with the real app: routes, pages and layout live here. import { Navigate, Route, Routes } from 'react-router-dom'
import { useZino } from './zino-sdk'
import Layout from './components/Layout'
import SignIn from './pages/SignIn'
import Home from './pages/Home'
import ListPage from './pages/ListPage'
import RecordPage from './pages/RecordPage'
import StepFormPage from './pages/StepFormPage'
function Protected() {
const { user } = useZino()
return user ? <Layout /> : <Navigate to="/login" replace />
}
export default function App() { export default function App() {
return ( return (
<main className="min-h-screen flex items-center justify-center p-6 text-center bg-slate-50 text-slate-800"> <Routes>
<div> <Route path="/login" element={<SignIn />} />
<h1 className="text-2xl font-semibold">Truck Desk</h1> <Route element={<Protected />}>
<p className="mt-2 text-slate-500">Truck Desk is on its way.</p> <Route path="/" element={<Home />} />
</div> <Route path="/lists/:listSlug" element={<ListPage />} />
</main> <Route path="/new" element={<StepFormPage start />} />
<Route path="/records/:id" element={<RecordPage />} />
<Route path="/records/:id/steps/:stepId" element={<StepFormPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
) )
} }

37
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useZino } from '../zino-sdk'
import { PROCESSES } from '../config'
const link = ({ isActive }: { isActive: boolean }) =>
`block px-3 py-1.5 rounded text-sm ${isActive ? 'bg-blue-50 text-blue-700 font-medium' : 'text-slate-700 hover:bg-slate-100'}`
export default function Layout() {
const { user, auth, setUser } = useZino()
const nav = useNavigate()
const signOut = async () => { await auth.logout(); setUser(null); nav('/login') }
return (
<div className="min-h-screen flex flex-col bg-slate-50 text-slate-800">
<header className="h-14 bg-white border-b border-slate-200 px-5 flex items-center justify-between">
<Link to="/" className="font-semibold">Truck Desk</Link>
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">{user?.name || user?.email}</span>
<button onClick={signOut} className="text-blue-700 hover:underline">Sign out</button>
</div>
</header>
<div className="flex flex-1">
<nav className="w-60 bg-white border-r border-slate-200 p-3 space-y-4" aria-label="Processes">
<NavLink to="/" end className={link}>Home</NavLink>
{PROCESSES.map((p) => (
<div key={p.slug}>
<div className="px-3 text-xs uppercase tracking-wide text-slate-400 mb-1">{p.name}</div>
{p.lists.map((l) => (
<NavLink key={l.slug} to={`/lists/${l.slug}`} className={link}>{l.name}</NavLink>
))}
</div>
))}
</nav>
<main className="flex-1 p-6 min-w-0"><Outlet /></main>
</div>
</div>
)
}

38
src/components/ui.tsx Normal file
View File

@ -0,0 +1,38 @@
import { type ReactNode } from 'react'
import { fieldDef, FIELD_DEFS } from '../config'
export function Loading({ text = 'Loading…' }: { text?: string }) {
return <div className="text-sm text-slate-500 py-6">{text}</div>
}
export function ErrorBox({ error }: { error: unknown }) {
const msg = (error as { message?: string })?.message ?? 'Something went wrong'
return <div role="alert" className="text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2 my-3">{msg}</div>
}
export function PageTitle({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-slate-800">{title}</h1>
<div className="flex gap-2">{children}</div>
</div>
)
}
export const btnPrimary = 'bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded'
export const btnSecondary = 'bg-white hover:bg-slate-100 border border-slate-300 text-slate-700 text-sm font-medium px-4 py-2 rounded'
export const inputCls = 'w-full border border-slate-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
/** Show a stored value as a person reads it: option labels, lists, dates. */
export function formatValue(key: string, value: unknown, dataType?: string): string {
if (value === null || value === undefined || value === '') return '—'
const def = FIELD_DEFS[key] ?? (key.includes('__') ? fieldDef(key) : undefined)
const label = (v: unknown) => def?.options?.find((o) => o.value === v)?.label ?? String(v)
if (Array.isArray(value)) return value.length ? value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : label(v))).join(', ') : '—'
if (typeof value === 'object') return JSON.stringify(value)
if (dataType === 'date' || dataType === 'datetime') {
const d = new Date(String(value))
if (!isNaN(d.getTime())) return dataType === 'date' ? d.toLocaleDateString() : d.toLocaleString()
}
return label(value)
}

View File

@ -1,9 +1,168 @@
// Workflow, step and view ids this frontend uses. Keep every id here and // Everything here was read from the app's configuration. Ids and keys live
// import it where it is needed; never write an id inline in a page. // here only; pages import them.
//
// Examples: export type FieldType = 'text' | 'longtext' | 'number' | 'date' | 'select' | 'checkbox' | 'file' | 'image'
// export const WORKFLOW_IDS = { TRUCK_ONBOARDING: 'truck-onboarding' } as const
// export const ACTIVITY_IDS = { START: 'start-registration' } as const export interface Option { label: string; value: string }
// export const RECORD_VIEW_IDS = { TRUCKS: 'rv-trucks' } as const
// export const DETAIL_VIEW_IDS = { TRUCK: 'dv-truck' } as const export interface FieldDef {
export {} label: string
type: FieldType
options?: Option[]
maxLength?: number
minValue?: number
pattern?: string
patternMessage?: string
placeholder?: string
uppercase?: boolean
minSelections?: number
futureOnly?: boolean
}
// Global (workflow data) field definitions, keyed by field key.
const FILE_OK = ['pdf', 'jpg', 'jpeg', 'png']
export const FIELD_DEFS: Record<string, FieldDef> = {
transport_type: { label: 'Transport Type', type: 'select', options: [{ label: 'Primary', value: 'primary' }, { label: 'Secondary', value: 'secondary' }] },
registration_number: { label: 'Registration Number', type: 'text', maxLength: 20, uppercase: true },
owner_name: { label: 'Owner Name', type: 'text', maxLength: 150 },
chassis_number: { label: 'Chassis Number', type: 'text', maxLength: 30, uppercase: true },
engine_number: { label: 'Engine Number', type: 'text', maxLength: 30, uppercase: true },
make_year: { label: 'Vehicle Make Year', type: 'text', pattern: '^(19|20)[0-9]{2}$', patternMessage: 'Enter a 4-digit year, e.g. 2021', placeholder: 'YYYY' },
body_type: { label: 'Body Type', type: 'select', options: [
{ label: 'Open Body', value: 'open_body' }, { label: 'Closed Container', value: 'closed_container' }, { label: 'Tanker', value: 'tanker' },
{ label: 'Tipper', value: 'tipper' }, { label: 'Flatbed', value: 'flatbed' }, { label: 'Trailer', value: 'trailer' } ] },
fuel_type: { label: 'Fuel Type', type: 'select', options: [{ label: 'Diesel', value: 'diesel' }, { label: 'CNG', value: 'cng' }, { label: 'Electric', value: 'electric' }] },
gross_weight_kg: { label: 'Gross Weight (kg)', type: 'number', minValue: 1 },
tare_weight_kg: { label: 'Tare Weight (kg)', type: 'number', minValue: 1 },
fitness_valid_until: { label: 'Fitness Valid Until', type: 'date', futureOnly: true },
insurance_valid_until: { label: 'Insurance Valid Until', type: 'date', futureOnly: true },
permit_valid_until: { label: 'Permit Valid Until', type: 'date', futureOnly: true },
puc_valid_until: { label: 'PUC Valid Until', type: 'date', futureOnly: true },
rc_document: { label: 'RC Document', type: 'file' },
permit_document: { label: 'Permit Document', type: 'file' },
fitness_certificate: { label: 'Fitness Certificate', type: 'file' },
puc_certificate: { label: 'PUC Certificate', type: 'file' },
insurance_document: { label: 'Insurance Document', type: 'file' },
side_view_photo: { label: 'Truck Side View Photo', type: 'image' },
plants: { label: 'Plants', type: 'checkbox', minSelections: 1, options: [{ label: 'Plant A', value: 'plant_a' }, { label: 'Plant B', value: 'plant_b' }, { label: 'Plant C', value: 'plant_c' }] },
transporter_name: { label: 'Transporter Name', type: 'text', maxLength: 150 },
send_back_remark: { label: 'Remark', type: 'longtext', maxLength: 2000 },
block_reason: { label: 'Block Reason', type: 'longtext', maxLength: 2000 },
unblock_reason: { label: 'Unblock Reason', type: 'longtext', maxLength: 2000 },
decision_remark: { label: 'Remark', type: 'longtext', maxLength: 2000 },
new_fitness_valid_until: { label: 'New Fitness Valid Until', type: 'date', futureOnly: true },
new_insurance_valid_until: { label: 'New Insurance Valid Until', type: 'date', futureOnly: true },
new_permit_valid_until: { label: 'New Permit Valid Until', type: 'date', futureOnly: true },
new_puc_valid_until: { label: 'New PUC Valid Until', type: 'date', futureOnly: true },
new_fitness_certificate: { label: 'New Fitness Certificate', type: 'file' },
new_insurance_document: { label: 'New Insurance Document', type: 'file' },
new_permit_document: { label: 'New Permit Document', type: 'file' },
new_puc_certificate: { label: 'New PUC Certificate', type: 'file' },
new_gross_weight_kg: { label: 'New Gross Weight (kg)', type: 'number', minValue: 1 },
new_tare_weight_kg: { label: 'New Tare Weight (kg)', type: 'number', minValue: 1 },
weight_change_reason: { label: 'Weight Change Reason', type: 'longtext', maxLength: 2000 },
}
export const FILE_ALLOWED_TYPES = FILE_OK
export interface ProcessDef {
id: string
slug: string
name: string
listViewId: string
detailViewId: string
initActivityId: string
}
export const WORKFLOW_IDS = { TRUCK_ONBOARDING: 'b5648076-d5c3-4b79-8b83-e77756354223' } as const
export const RECORD_VIEW_IDS = {
TRUCK_REGISTRATIONS: '4dbf37d2-25bc-4137-9839-c1e8971a9fd3',
TRUCK_MASTER: '66137910-ad06-4465-8ec9-3f65e6e08728',
} as const
export const DETAIL_VIEW_IDS = { REGISTRATION_DETAILS: 'e816a670-ffb4-4487-a18e-99761a0597dc' } as const
// A record view has one list page each; the process is the workflow.
export interface ListDef { slug: string; name: string; viewId: string }
export const LISTS: ListDef[] = [
{ slug: 'registrations', name: 'Truck Registrations', viewId: RECORD_VIEW_IDS.TRUCK_REGISTRATIONS },
{ slug: 'master', name: 'Truck Master', viewId: RECORD_VIEW_IDS.TRUCK_MASTER },
]
export const PROCESSES = [
{
slug: 'truck-onboarding',
name: 'Truck Onboarding',
workflowId: WORKFLOW_IDS.TRUCK_ONBOARDING,
detailViewId: DETAIL_VIEW_IDS.REGISTRATION_DETAILS,
lists: LISTS,
},
]
export interface StepDef {
id: string
name: string
init: boolean
roles: string[]
/** [activity field key, required] — the global field key is the part before "__". */
fields: [string, boolean][]
/** Record states in which the step is offered (empty for the start step). */
states: string[]
}
const T = 'Transporter', PL = 'Plant Logistics'
const resubFields: [string, boolean][] = [
['transport_type__resub', true], ['registration_number__resub', true], ['owner_name__resub', true], ['chassis_number__resub', true],
['engine_number__resub', true], ['make_year__resub', true], ['body_type__resub', true], ['fuel_type__resub', true],
['gross_weight_kg__resub', true], ['tare_weight_kg__resub', true], ['fitness_valid_until__resub', true],
['insurance_valid_until__resub', true], ['permit_valid_until__resub', true], ['puc_valid_until__resub', true],
['rc_document__resub', false], ['permit_document__resub', false], ['fitness_certificate__resub', false],
['puc_certificate__resub', false], ['insurance_document__resub', false], ['side_view_photo__resub', false],
['plants__resub', true], ['transporter_name__resub', true],
]
export const STEPS: StepDef[] = [
{ id: '864ba04e-5d32-4245-897d-61b8638dcd66', name: 'Start Registration', init: true, roles: [PL, T], states: [],
fields: [['transport_type__start', true], ['registration_number__start', true]] },
{ id: '8970508b-ccba-440d-b3d7-9a2f196a1998', name: 'Enter Vehicle Info', init: false, roles: [PL, T], states: ['Registration Initiated'],
fields: [['owner_name__vi', true], ['chassis_number__vi', true], ['engine_number__vi', true], ['make_year__vi', true], ['body_type__vi', true],
['fuel_type__vi', true], ['gross_weight_kg__vi', true], ['tare_weight_kg__vi', true], ['fitness_valid_until__vi', true],
['insurance_valid_until__vi', true], ['permit_valid_until__vi', true], ['puc_valid_until__vi', true]] },
{ id: '01bc83be-c1a3-4e69-90e0-e79ae82022a0', name: 'Upload Documents', init: false, roles: [PL, T], states: ['Documents Upload Pending'],
fields: [['rc_document__docs', true], ['permit_document__docs', true], ['fitness_certificate__docs', true], ['puc_certificate__docs', true],
['insurance_document__docs', true], ['side_view_photo__docs', true]] },
{ id: 'fd89ec73-2dcb-439c-ad65-c72e9cf4930c', name: 'Assign Plants & Submit', init: false, roles: [PL, T], states: ['Plant Assignment Pending'],
fields: [['plants__plants', true], ['transporter_name__plants', true]] },
{ id: 'f3e1c4db-bbc8-4407-bf37-c90f8d23072e', name: 'MDM Approve', init: false, roles: ['MDM'], states: ['MDM Approval Pending'], fields: [] },
{ id: 'e7d27564-149b-429f-b1f2-4a6d683b414c', name: 'MDM Send Back', init: false, roles: ['MDM'], states: ['MDM Approval Pending'], fields: [['send_back_remark__mdm', true]] },
{ id: 'ce54f2ec-954c-4dfe-bf97-db0b7942dd76', name: 'MDM Cancel', init: false, roles: ['MDM'], states: ['MDM Approval Pending'], fields: [] },
{ id: '35e4bf61-fb72-4edf-a264-a3bb1bbf29e3', name: 'RLH Approve', init: false, roles: ['RLH'], states: ['RLH Approval Pending'], fields: [] },
{ id: 'b3927372-717b-4ad4-9969-f8b0e609005b', name: 'RLH Reject', init: false, roles: ['RLH'], states: ['RLH Approval Pending'], fields: [['send_back_remark__rlh', true]] },
{ id: 'f71e2f5e-f3fe-4e6a-a027-039a0518aa13', name: 'Correct & Resubmit', init: false, roles: [PL, T], states: ['Sent Back'], fields: resubFields },
{ id: 'ff5495ea-b867-45df-8cb9-632e3115e26a', name: 'Admin Cancel', init: false, roles: [PL],
states: ['Registration Initiated', 'Documents Upload Pending', 'Plant Assignment Pending', 'MDM Approval Pending', 'RLH Approval Pending', 'Sent Back', 'SAP Failed'], fields: [] },
{ id: 'a6ec32d6-2728-4610-8d0f-1cf8654330c5', name: 'Retry SAP', init: false, roles: [PL], states: ['SAP Failed'], fields: [] },
{ id: '4eec9dd3-403a-4a66-b441-24af35744834', name: 'Request Block', init: false, roles: [PL], states: ['Registered'], fields: [['block_reason__reqblock', true]] },
{ id: '35902a58-a4d3-42ee-9772-005a03d75340', name: 'RLH Approve Block', init: false, roles: ['RLH'], states: ['Block Approval Pending'], fields: [] },
{ id: 'fd39db71-967a-4732-9851-4ec21a0beca5', name: 'RLH Reject Block', init: false, roles: ['RLH'], states: ['Block Approval Pending'], fields: [['decision_remark__rlhrejblock', true]] },
{ id: 'ae28badf-f30b-49d0-8099-087273602709', name: 'Request Unblock', init: false, roles: [PL], states: ['Blocked'], fields: [['unblock_reason__requnblock', false]] },
{ id: '2652b292-550d-4d20-b103-876ab00940e0', name: 'RLH Approve Unblock', init: false, roles: ['RLH'], states: ['Unblock RLH Approval Pending'], fields: [] },
{ id: 'a436b6e1-91cf-492a-8f25-44c09e28fe27', name: 'RLH Reject Unblock', init: false, roles: ['RLH'], states: ['Unblock RLH Approval Pending'], fields: [['decision_remark__rlhrejunblock', true]] },
{ id: '8d4825eb-32d1-4dbd-8db0-674247e2832c', name: 'HO Approve Unblock', init: false, roles: ['HO Approver'], states: ['Unblock HO Approval Pending'], fields: [] },
{ id: '5c6c3bc9-54fa-4ea5-9d17-2e92b5a23c45', name: 'HO Reject Unblock', init: false, roles: ['HO Approver'], states: ['Unblock HO Approval Pending'], fields: [['decision_remark__horejunblock', true]] },
{ id: '192d77a5-e5a2-4dce-a768-f92e7f2e36b9', name: 'Request Validity Update', init: false, roles: [T], states: ['Registered', 'Blocked'],
fields: [['new_fitness_valid_until__reqval', true], ['new_insurance_valid_until__reqval', true], ['new_permit_valid_until__reqval', true], ['new_puc_valid_until__reqval', true],
['new_fitness_certificate__reqval', false], ['new_insurance_document__reqval', false], ['new_permit_document__reqval', false], ['new_puc_certificate__reqval', false]] },
{ id: '6c520b12-b3c5-4920-a64f-2e693e5589a0', name: 'MDM Approve Validity Update', init: false, roles: ['MDM'], states: ['Validity Update Pending'], fields: [] },
{ id: '32f4b599-efd3-44ed-8931-e5fafebf780e', name: 'MDM Reject Validity Update', init: false, roles: ['MDM'], states: ['Validity Update Pending'], fields: [['decision_remark__mdmrejval', true]] },
{ id: '3fdcb3f5-501d-45ba-a907-7cb1a626075d', name: 'Request Weight Edit', init: false, roles: [PL], states: ['Registered', 'Blocked'],
fields: [['new_gross_weight_kg__reqwt', true], ['new_tare_weight_kg__reqwt', true], ['weight_change_reason__reqwt', true]] },
{ id: '9446f10c-6ee7-465f-af41-f43aaac3df7c', name: 'RLH Approve Weight Edit', init: false, roles: ['RLH'], states: ['Weight Update Pending'], fields: [] },
{ id: '56e36543-54e9-45d9-ab18-9a81094a2f23', name: 'RLH Reject Weight Edit', init: false, roles: ['RLH'], states: ['Weight Update Pending'], fields: [['decision_remark__rlhrejwt', true]] },
]
export const INIT_STEP = STEPS.find((s) => s.init)!
export const stepById = (id: string) => STEPS.find((s) => s.id === id)
export const fieldDef = (activityKey: string): FieldDef => FIELD_DEFS[activityKey.split('__')[0]]
// Record view columns that identify a record / its state.
export const SYSTEM_KEYS = { RECORD_ID: 'instance_id', STATE: 'current_state_name' } as const

24
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,24 @@
import { Link } from 'react-router-dom'
import { PROCESSES } from '../config'
import { PageTitle } from '../components/ui'
export default function Home() {
return (
<div>
<PageTitle title="Home" />
<p className="text-sm text-slate-600 mb-4">Choose a process list to work on.</p>
<div className="grid gap-4 sm:grid-cols-2">
{PROCESSES.map((p) => (
<div key={p.slug} className="bg-white border border-slate-200 rounded p-4">
<h2 className="font-semibold mb-2">{p.name}</h2>
<ul className="space-y-1 text-sm">
{p.lists.map((l) => (
<li key={l.slug}><Link className="text-blue-700 hover:underline" to={`/lists/${l.slug}`}>{l.name}</Link></li>
))}
</ul>
</div>
))}
</div>
</div>
)
}

71
src/pages/ListPage.tsx Normal file
View File

@ -0,0 +1,71 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQuery, useZino } from '../zino-sdk'
import { LISTS, SYSTEM_KEYS } from '../config'
import { ErrorBox, Loading, PageTitle, btnPrimary, formatValue, inputCls } from '../components/ui'
const PAGE_SIZE = 20
export default function ListPage() {
const { listSlug } = useParams()
const list = LISTS.find((l) => l.slug === listSlug)
const { views } = useZino()
const nav = useNavigate()
const [page, setPage] = useState(1)
const [text, setText] = useState('')
const [search, setSearch] = useState('')
const q = useQuery({
queryKey: ['recordview', list?.viewId, page, search],
queryFn: () => views.getTabularView(list!.viewId, { page, pageSize: PAGE_SIZE, search: search || undefined }),
enabled: !!list,
})
if (!list) return <ErrorBox error={{ message: 'Unknown list' }} />
const cols = q.data?.columns ?? []
const totalPages = q.data?.totalPages ?? Math.max(1, Math.ceil((q.data?.total ?? 0) / PAGE_SIZE))
return (
<div>
<PageTitle title={list.name}>
<Link to={`/new`} className={btnPrimary}>New</Link>
</PageTitle>
<form className="flex gap-2 mb-4 max-w-md" onSubmit={(e) => { e.preventDefault(); setPage(1); setSearch(text.trim()) }}>
<input className={inputCls} placeholder="Search" aria-label="Search" value={text} onChange={(e) => setText(e.target.value)} />
<button className="bg-white border border-slate-300 text-sm px-4 rounded hover:bg-slate-100">Search</button>
</form>
{q.isLoading && <Loading />}
{q.error != null && <ErrorBox error={q.error} />}
{q.data && (
<>
<div className="overflow-x-auto bg-white border border-slate-200 rounded">
<table className="w-full text-sm">
<thead className="bg-slate-100 text-left text-slate-600">
<tr>{cols.map((c) => <th key={c.key} className="px-3 py-2 font-medium whitespace-nowrap">{c.label}</th>)}</tr>
</thead>
<tbody>
{q.data.rows.length === 0 && <tr><td className="px-3 py-6 text-slate-500" colSpan={cols.length || 1}>No records found.</td></tr>}
{q.data.rows.map((r, i) => {
const id = String(r[SYSTEM_KEYS.RECORD_ID] ?? '')
return (
<tr key={id || i} className="border-t border-slate-100 hover:bg-blue-50 cursor-pointer" onClick={() => id && nav(`/records/${encodeURIComponent(id)}`)}>
{cols.map((c) => <td key={c.key} className="px-3 py-2 whitespace-nowrap">{formatValue(c.key, r[c.key], c.data_type)}</td>)}
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-3 text-sm text-slate-600">
<span>{q.data.total} record{q.data.total === 1 ? '' : 's'}</span>
<div className="flex items-center gap-3">
<button disabled={page <= 1} onClick={() => setPage(page - 1)} className="disabled:opacity-40 text-blue-700">Previous</button>
<span>Page {page} of {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} className="disabled:opacity-40 text-blue-700">Next</button>
</div>
</div>
</>
)}
</div>
)
}

71
src/pages/RecordPage.tsx Normal file
View File

@ -0,0 +1,71 @@
import { Link, useParams } from 'react-router-dom'
import { useQuery, useZino } from '../zino-sdk'
import { DETAIL_VIEW_IDS, FIELD_DEFS, STEPS, SYSTEM_KEYS, type StepDef } from '../config'
import { ErrorBox, Loading, PageTitle, btnPrimary, btnSecondary, formatValue } from '../components/ui'
import { useState } from 'react'
import { useMutation, useQueryClient } from '../zino-sdk'
import { WORKFLOW_IDS } from '../config'
function canPerform(step: StepDef, roles: string[]) {
if (roles.length === 0) return true // roles unknown: the server decides
const mine = roles.map((r) => r.toLowerCase())
return step.roles.some((r) => mine.includes(r.toLowerCase()))
}
export default function RecordPage() {
const { id = '' } = useParams()
const { views, user, workflows } = useZino()
const qc = useQueryClient()
const [done, setDone] = useState<string | null>(null)
const q = useQuery({
queryKey: ['detailview', id],
queryFn: () => views.getDetailView(DETAIL_VIEW_IDS.REGISTRATION_DETAILS, id),
})
const quick = useMutation({
mutationFn: (step: StepDef) => workflows.performActivity(WORKFLOW_IDS.TRUCK_ONBOARDING, id, step.id, {}),
onSuccess: (_r, step) => { setDone(step.name); qc.invalidateQueries({ queryKey: ['detailview'] }); qc.invalidateQueries({ queryKey: ['recordview'] }) },
})
const state = q.data ? String(q.data.data[SYSTEM_KEYS.STATE] ?? '') : ''
const steps = STEPS.filter((s) => !s.init && s.states.includes(state) && canPerform(s, user?.roles ?? []))
return (
<div>
<PageTitle title={`Record ${id}`}>
<button className={btnSecondary} onClick={() => history.back()}>Back</button>
</PageTitle>
{q.isLoading && <Loading />}
{q.error != null && <ErrorBox error={q.error} />}
{q.data && (
<>
<section className="bg-white border border-slate-200 rounded p-4 mb-4">
<h2 className="font-semibold mb-3">Steps you can perform</h2>
{done && <div className="text-sm text-green-700 mb-2">“{done}” done.</div>}
{quick.error != null && <ErrorBox error={quick.error} />}
{steps.length === 0 ? <p className="text-sm text-slate-500">No steps available for you in the current state{state ? ` (${state})` : ''}.</p> : (
<div className="flex flex-wrap gap-2">
{steps.map((s) => s.fields.length > 0
? <Link key={s.id} className={btnPrimary} to={`/records/${encodeURIComponent(id)}/steps/${s.id}`}>{s.name}</Link>
: <button key={s.id} className={btnPrimary} disabled={quick.isPending}
onClick={() => { if (confirm(`${s.name}?`)) quick.mutate(s) }}>{s.name}</button>)}
</div>
)}
</section>
{q.data.sections.map((sec) => (
<section key={sec.title} className="bg-white border border-slate-200 rounded p-4">
<h2 className="font-semibold mb-3">{sec.title}</h2>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3 text-sm">
{sec.fields.map((f) => (
<div key={f.label}>
<dt className="text-slate-500">{f.label}</dt>
<dd className="break-words">{formatValue(Object.keys(FIELD_DEFS).find((k) => FIELD_DEFS[k].label === f.label) ?? '', f.value)}</dd>
</div>
))}
</dl>
</section>
))}
</>
)}
</div>
)
}

39
src/pages/SignIn.tsx Normal file
View File

@ -0,0 +1,39 @@
import { useState, type FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useZino } from '../zino-sdk'
import { ErrorBox, btnPrimary, inputCls } from '../components/ui'
export default function SignIn() {
const { auth, user, setUser } = useZino()
const nav = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<unknown>(null)
if (user) return <Navigate to="/" replace />
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true); setError(null)
try {
const res = await auth.login(email, password)
setUser(res.user ?? auth.getCurrentUser())
nav('/', { replace: true })
} catch (err) { setError(err) } finally { setBusy(false) }
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
<form onSubmit={submit} className="w-full max-w-sm bg-white border border-slate-200 rounded p-6 space-y-4">
<h1 className="text-xl font-semibold text-slate-800">Sign in to Truck Desk</h1>
<label className="block text-sm text-slate-700">Email
<input className={inputCls + ' mt-1'} type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
</label>
<label className="block text-sm text-slate-700">Password
<input className={inputCls + ' mt-1'} type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</label>
{error != null && <ErrorBox error={error} />}
<button className={btnPrimary + ' w-full'} disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
</div>
)
}

108
src/pages/StepFormPage.tsx Normal file
View File

@ -0,0 +1,108 @@
import { useState, type FormEvent } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQueryClient, useZino } from '../zino-sdk'
import { FILE_ALLOWED_TYPES, INIT_STEP, WORKFLOW_IDS, fieldDef, stepById, type FieldDef } from '../config'
import { ErrorBox, PageTitle, btnPrimary, btnSecondary, inputCls } from '../components/ui'
type Val = string | string[]
function validate(key: string, def: FieldDef, required: boolean, v: Val | undefined): string | null {
const empty = v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
if (empty) return required ? 'Required' : null
if (typeof v === 'string') {
if (def.pattern && !new RegExp(def.pattern).test(v)) return def.patternMessage ?? 'Invalid format'
if (def.maxLength && v.length > def.maxLength) return `At most ${def.maxLength} characters`
if (def.type === 'number' && def.minValue !== undefined && Number(v) < def.minValue) return `Must be at least ${def.minValue}`
if (def.futureOnly && def.type === 'date' && v < new Date().toISOString().slice(0, 10)) return 'Must be today or a future date'
}
if (Array.isArray(v) && def.minSelections && v.length < def.minSelections) return `Select at least ${def.minSelections}`
return null
}
function readFile(f: File): Promise<string> {
return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(String(r.result)); r.onerror = () => rej(r.error); r.readAsDataURL(f) })
}
export default function StepFormPage({ start }: { start?: boolean }) {
const params = useParams()
const step = start ? INIT_STEP : stepById(params.stepId ?? '')
const instanceId = start ? undefined : params.id
const { workflows } = useZino()
const qc = useQueryClient()
const nav = useNavigate()
const [values, setValues] = useState<Record<string, Val>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const submit = useMutation({
mutationFn: (data: Record<string, Val>) => start
? workflows.startWorkflow(WORKFLOW_IDS.TRUCK_ONBOARDING, step!.id, data)
: workflows.performActivity(WORKFLOW_IDS.TRUCK_ONBOARDING, instanceId!, step!.id, data),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['recordview'] }); qc.invalidateQueries({ queryKey: ['detailview'] })
nav(`/records/${encodeURIComponent(r.instance_id || instanceId || '')}`)
},
})
if (!step) return <ErrorBox error={{ message: 'Unknown step' }} />
const set = (k: string, v: Val) => setValues((p) => ({ ...p, [k]: v }))
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const errs: Record<string, string> = {}
for (const [k, req] of step.fields) { const m = validate(k, fieldDef(k), req, values[k]); if (m) errs[k] = m }
setErrors(errs)
if (Object.keys(errs).length === 0) submit.mutate(values)
}
return (
<div className="max-w-2xl">
<PageTitle title={step.name} />
<form onSubmit={onSubmit} noValidate className="bg-white border border-slate-200 rounded p-5 space-y-4">
{step.fields.map(([k, req]) => {
const def = fieldDef(k)
const v = values[k]
return (
<div key={k}>
<label className="block text-sm font-medium text-slate-700 mb-1" htmlFor={k}>
{def.label}{req && <span className="text-red-600"> *</span>}
</label>
{def.type === 'longtext' && <textarea id={k} rows={4} className={inputCls} maxLength={def.maxLength} value={(v as string) ?? ''} onChange={(e) => set(k, e.target.value)} />}
{(def.type === 'text' || def.type === 'number' || def.type === 'date') && (
<input id={k} className={inputCls} type={def.type} placeholder={def.placeholder} maxLength={def.maxLength} min={def.type === 'number' ? def.minValue : def.futureOnly ? new Date().toISOString().slice(0, 10) : undefined}
value={(v as string) ?? ''} onChange={(e) => set(k, def.uppercase ? e.target.value.toUpperCase() : e.target.value)} />
)}
{def.type === 'select' && (
<select id={k} className={inputCls} value={(v as string) ?? ''} onChange={(e) => set(k, e.target.value)}>
<option value="">Select…</option>
{def.options!.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{def.type === 'checkbox' && (
<div className="flex gap-4 flex-wrap">
{def.options!.map((o) => {
const arr = (v as string[]) ?? []
return (
<label key={o.value} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={arr.includes(o.value)} onChange={(e) => set(k, e.target.checked ? [...arr, o.value] : arr.filter((x) => x !== o.value))} />
{o.label}
</label>
)
})}
</div>
)}
{(def.type === 'file' || def.type === 'image') && (
<input id={k} type="file" accept={(def.type === 'image' ? ['jpg', 'jpeg', 'png'] : FILE_ALLOWED_TYPES).map((x) => '.' + x).join(',')} className="text-sm"
onChange={async (e) => { const f = e.target.files?.[0]; set(k, f ? [await readFile(f)] : []) }} />
)}
{errors[k] && <p className="text-xs text-red-600 mt-1">{errors[k]}</p>}
</div>
)
})}
{submit.error != null && <ErrorBox error={submit.error} />}
<div className="flex gap-2 pt-2">
<button className={btnPrimary} disabled={submit.isPending}>{submit.isPending ? 'Submitting…' : 'Submit'}</button>
<button type="button" className={btnSecondary} onClick={() => history.back()}>Cancel</button>
</div>
</form>
</div>
)
}