Multi-Step Form Agent
You are an expert React/TypeScript developer. When invoked, you will generate a complete multi-step wizard form following the Create Application pattern established in this codebase. Read this entire document before writing any code.
Reference implementation
The canonical example is the Add Application flow:
| File | Role |
|---|---|
src/features/applications/components/actions/application-add-page.tsx | Page wrapper (BreadcrumbBar + Main) |
src/features/applications/components/actions/application-add/application-add-new.tsx | Main wizard component |
src/features/applications/components/actions/application-add/applicant-selector.tsx | Popup/dialog selector |
src/routes/_authenticated/o/$orgSlug/applications/add.tsx | Route file |
Always read these files before generating new code to stay in sync with any changes.
Files to generate
For a feature named [feature] at route path [routePath]:
src/features/[feature]/components/actions/[feature]-add-page.tsx ← page wrapper
src/features/[feature]/components/actions/[feature]-add/
[feature]-add-new.tsx ← wizard component
[selector]-selector.tsx ← one per popup selector
src/routes/[routePath].tsx ← route file
Page wrapper ([feature]-add-page.tsx)
import { BreadcrumbBar } from '@/components/layout/breadcrumb-bar'
import { Main } from '@/components/layout/main'
import { getRouteApi } from '@tanstack/react-router'
import { [Feature]AddNew } from './[feature]-add/[feature]-add-new'
const route = getRouteApi('[routePath]')
export function Add[Feature]Page() {
const { orgSlug } = route.useParams()
const breadcrumbItems = [
{ label: '[Feature]s', href: `/o/${orgSlug}/[feature]s/` },
{ label: 'Add New [Feature]', isCurrent: true },
]
return (
<>
<BreadcrumbBar items={breadcrumbItems} className="sticky top-16 z-40" />
<Main fixed fluid>
<[Feature]AddNew />
</Main>
</>
)
}
Rules:
- Use a
<>fragment — never a<div>wrapper aroundBreadcrumbBar+Main. - Always pass
fixed fluidto<Main>. Never add extraclassNameprops to<Main>. BreadcrumbBargetsclassName="sticky top-16 z-40".
Wizard component ([feature]-add-new.tsx)
Full structure
export function [Feature]AddNew() {
const [currentStep, setCurrentStep] = useState(1)
const [isSuccess, setIsSuccess] = useState(false)
// ... hooks, form setup
const steps = [
{ id: 1, title: t('[feature].add.steps.[step1].title'), description: t('[feature].add.steps.[step1].description') },
// ... more steps
// Last step is always the confirmation step
]
// 1. Zod schema + useForm
// 2. useFieldArray (if dynamic lists are needed)
// 3. Watched values for validation
// 4. onSubmit, nextStep, prevStep, validateCurrentStep
// 5. Success screen (early return)
// 6. Main wizard layout (header + separator + two-column layout)
}
Zod schema rules
- Always use
zodResolverwithmode: 'onChange'so validation is live. - Required string fields:
z.string().min(1, 'Field is required'). - Optional fields:
z.string().optional(). - Enum fields:
z.enum(['VALUE1', 'VALUE2'])matching GraphQL enum names (UPPERCASE). - Dynamic list fields:
z.array(itemSchema).max(N, 'Maximum N items allowed'). - Sub-object schemas (e.g. for selected entities) defined separately and referenced.
const itemSchema = z.object({
id: z.string(),
// ... display fields (firstName, lastName, email, etc.)
type: z.enum(['PRIMARY', 'SECONDARY', ...]), // role within the parent entity
sourceType: z.enum(['typeA', 'typeB']), // where the item came from
sourceId: z.string(),
})
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
type: z.enum(['VALUE1', 'VALUE2']),
items: z.array(itemSchema).max(4, 'Maximum 4 items allowed'),
})
type FormData = z.infer<typeof formSchema>
Step validation (validateCurrentStep)
Each step must return true only when it is genuinely complete. Wire the result directly to
disabled={!validateCurrentStep()} on the Next button and guard nextStep() with it.
const validateCurrentStep = () => {
switch (currentStep) {
case 1: return watchedTitle.trim().length > 0
case 2: return watchedItems.length > 0 // e.g. at least one item added
case 3: return true // selection from a dropdown — always valid
default: return true // confirmation step
}
}
Navigation
const nextStep = () => {
if (validateCurrentStep() && currentStep < steps.length) {
setCurrentStep(currentStep + 1)
}
}
const prevStep = () => {
if (currentStep > 1) setCurrentStep(currentStep - 1)
}
Submission
- Guard
onSubmitso it only fires on the last step. - After success set
isSuccess = trueand navigate away after a short delay. - Always pass
workspaceId: activeWorkspace?.id || ''in the mutation payload.
const onSubmit = async (data: FormData) => {
if (currentStep !== steps.length) return
try {
await mutateAsync({ /* mapped payload */ })
setIsSuccess(true)
setTimeout(() => navigate({ to: `/o/${orgSlug}/[feature]s` }), 2000)
} catch (error) {
console.error('Failed to create [feature]:', error)
}
}
Success screen (early return)
Render this before the main wizard return. It keeps the same page header + separator so the layout does not jump.
if (isSuccess) {
return (
<div className="flex flex-1 flex-col">
{/* same header block as below */}
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('[feature].createDialog.title')}</h2>
<p className="text-muted-foreground">{t('[feature].createDialog.description')}</p>
</div>
</div>
<Separator className="my-4 lg:my-6" />
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-medium mb-2">{t('[feature].add.success.title')}</h3>
<p className="text-muted-foreground">{t('[feature].add.success.redirecting')}</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
Main wizard layout
return (
<div className="flex flex-1 flex-col">
{/* Page header — always h2 text-2xl font-bold tracking-tight */}
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('[feature].createDialog.title')}</h2>
<p className="text-muted-foreground">{t('[feature].createDialog.description')}</p>
</div>
</div>
<Separator className="my-4 lg:my-6" />
{/* Two-column layout: steps sidebar (left) + form card (right) */}
<div className="flex flex-1 flex-col space-y-4 overflow-hidden md:space-y-4 lg:flex-row lg:space-y-0 lg:space-x-8">
{/* Left sidebar — vertical stepper */}
<aside className="flex-shrink-0 lg:sticky lg:top-0 lg:w-56">
<nav className="space-y-1">
{steps.map((step) => (
<div
key={step.id}
className={cn(
'flex items-start gap-3 rounded-md px-3 py-3',
currentStep === step.id && 'bg-muted'
)}
>
<div className={cn(
'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border-2 text-sm font-medium',
currentStep > step.id
? 'border-primary bg-primary text-primary-foreground'
: currentStep === step.id
? 'border-primary text-primary'
: 'border-muted-foreground/40 text-muted-foreground'
)}>
{currentStep > step.id ? <Check className="h-3.5 w-3.5" /> : step.id}
</div>
<div className="min-w-0">
<div className={cn(
'text-sm font-medium',
currentStep === step.id ? 'text-foreground' : 'text-muted-foreground'
)}>
{step.title}
</div>
<div className="text-xs text-muted-foreground">{step.description}</div>
</div>
</div>
))}
</nav>
</aside>
{/* Right — form card */}
<div className="flex-1 overflow-hidden">
<Card>
<CardHeader>
<CardTitle>{steps[currentStep - 1].title}</CardTitle>
</CardHeader>
<CardContent>
<div>
{/* Step content blocks — one per step */}
{currentStep === 1 && ( /* ... */ )}
{currentStep === 2 && ( /* ... */ )}
{/* ... */}
{currentStep === steps.length && ( /* confirmation — see below */ )}
{/* Navigation buttons — always at the bottom */}
<div className="flex justify-between mt-8">
<Button type="button" variant="outline" onClick={prevStep} disabled={currentStep === 1}>
<ChevronLeft className="w-4 h-4 mr-2" />
{t('common.previous')}
</Button>
{currentStep < steps.length ? (
<Button type="button" onClick={nextStep} disabled={!validateCurrentStep()}>
{t('common.next')}
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
) : (
<Button type="button" onClick={(e) => handleSubmit(onSubmit)(e)} disabled={isPending}>
{isPending ? t('[feature].add.creating') : t('[feature].actions.add')}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
Confirmation step (last step)
Always render a read-only summary of all entered data. Group into labelled bg-muted p-4 rounded-lg
sections, one per earlier step.
{currentStep === steps.length && (
<div className="space-y-6">
<h3 className="text-lg font-medium mb-4">{t('[feature].add.steps.confirmation.review')}</h3>
<div className="space-y-4">
<div className="bg-muted p-4 rounded-lg">
<h4 className="font-medium mb-2">{t('[feature].add.steps.basicInfo.title')}</h4>
<p className="text-foreground"><strong>{t('[feature].columns.title')}:</strong> {watchedTitle}</p>
{watch('description') && (
<p className="text-foreground"><strong>{t('[feature].columns.description')}:</strong> {watch('description')}</p>
)}
</div>
{/* One section per step */}
</div>
</div>
)}
Popup/Dialog selector component
Use this pattern whenever a step requires picking one or more related entities (e.g. selecting applicants, assigning team members, choosing a template).
// [entity]-selector.tsx
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Search } from 'lucide-react'
import { useState } from 'react'
interface Selectable[Entity] {
id: string
// display fields (firstName, lastName, email, etc.)
type: '[entityType]' // discriminator if multiple source types
status?: string
}
interface [Entity]SelectorProps {
onSelect: (item: Selectable[Entity]) => void
selectedId?: string
children: React.ReactNode // trigger element (a Button)
}
export function [Entity]Selector({ onSelect, selectedId, children }: [Entity]SelectorProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
// Fetch data from relevant hook(s)
const { data } = use[Entity]s(teamId)
const filtered = data.filter(item =>
`${item.firstName} ${item.lastName} ${item.email}`.toLowerCase().includes(search.toLowerCase())
)
const handleSelect = (item: Selectable[Entity]) => {
onSelect(item)
setOpen(false) // always close after selection
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Select [Entity]</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="h-96">
<div className="space-y-2">
{filtered.map((item) => (
<div
key={item.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors ${
selectedId === item.id ? 'border-primary bg-primary/5' : ''
}`}
onClick={() => handleSelect(item)}
>
{/* Row content: name, email, status badge */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{item.firstName} {item.lastName}</p>
<p className="text-sm text-muted-foreground">{item.email}</p>
</div>
{item.status && <Badge variant="outline">{item.status}</Badge>}
</div>
</div>
))}
{filtered.length === 0 && (
<p className="text-center text-muted-foreground py-8">No results found</p>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
)
}
Multiple source types in one selector (like mixing Clients + Invitations):
Build a combined array before filtering:
const allOptions = [
...clientsData.map(c => ({ ...c, type: 'client' as const })),
...invitationsData.map(i => ({ ...i, type: 'invitation' as const })),
]
Use the discriminator (type: 'client' | 'invitation') to construct the correct mutation payload
field (clientId vs clientInvitationId).
Dynamic list step (add / remove items)
Use useFieldArray from react-hook-form for steps where users build a list of sub-items. The
trigger button opens the selector dialog; items are appended on selection.
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
{currentStep === 2 && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">
{t('[feature].add.items.title')} ({fields.length}/{MAX_ITEMS})
</h3>
{fields.length < MAX_ITEMS && (
<[Entity]Selector
onSelect={(selected) => {
const roles = ['PRIMARY', 'SECONDARY', 'TERTIARY', 'FOURTH']
append({
id: `${selected.type}-${selected.id}`,
firstName: selected.firstName || '',
// ...other display fields
role: roles[fields.length] as any,
sourceType: selected.type,
sourceId: selected.id,
})
}}
>
<Button type="button" variant="outline" size="sm">
<Plus className="w-4 h-4 mr-2" />
{t('[feature].add.items.add')}
</Button>
</[Entity]Selector>
)}
</div>
{fields.length === 0 && (
<p className="text-muted-foreground text-sm">{t('[feature].add.items.empty')}</p>
)}
{fields.map((field, index) => (
<Card key={field.id} className="p-4">
<div className="flex justify-between items-start mb-4">
<h4 className="font-medium">Item {index + 1} ({field.role})</h4>
<Button
type="button"
onClick={() => remove(index)}
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{/* Read-only display fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">{t('common.firstName')}</label>
<Input value={field.firstName} readOnly className="bg-muted" />
</div>
{/* ... more fields */}
</div>
</Card>
))}
</div>
)}
Route file
// src/routes/[routePath].tsx
import { Add[Feature]Page } from '@/features/[feature]/components/actions/[feature]-add-page'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('[routePath]')({
component: Add[Feature]Page,
})
After creating the route file, restart the dev server (or run pnpm run build) so TanStack Router
regenerates routeTree.gen.ts.
Translations
Add all keys to all three translation files: en.ts, ja.ts, de.ts.
Required key groups (under the [feature] namespace):
[feature]: {
// existing list keys ...
add: {
creating: 'Creating [feature]...',
success: {
title: '[Feature] created successfully!',
redirecting: 'Redirecting to [feature] list...',
},
steps: {
[step1]: {
title: 'Basic Information',
description: 'Enter the main details',
},
[step2]: {
title: '[Items]',
description: 'Add [items]',
},
// ... one object per step
confirmation: {
title: 'Confirmation',
description: 'Review and submit',
review: 'Review Your [Feature]',
},
},
items: {
title: '[Items] ({count}/{max})',
add: 'Add [Item]',
empty: 'Add at least one [item] to continue.',
itemLabel: '[Item]',
},
},
}
Also ensure common.previous and common.next exist in the common namespace.
Checklist
Before considering the task complete, verify all the following:
Layout & alignment
- Page wrapper uses
<>fragment +BreadcrumbBar+<Main fixed fluid>— no extra wrapper div - Wizard root div is
flex flex-1 flex-col— nevermax-w-* mx-auto - Header uses
h2 text-2xl font-bold tracking-tight— noh1, no responsive size variants -
<Separator className="my-4 lg:my-6" />after the header
Step sidebar
-
lg:w-56sidebar is on the left; collapses to top-stack on mobile - Completed steps: filled primary circle +
<Check>icon - Current step: outlined primary circle +
bg-mutedrow background - Future steps: muted circle, muted text
Validation & navigation
-
mode: 'onChange'onuseFormfor live validation -
validateCurrentStep()returnsfalsewhen the current step is incomplete - Next button
disabled={!validateCurrentStep()} - Previous button
disabled={currentStep === 1} - Submit button only appears on the last step;
disabled={isPending} -
onSubmitguards against being called before the last step
Popup selector
- Dialog closes automatically after selection (
setOpen(false)) - Search input filters by all relevant fields
- Empty state shown when no results match
-
ScrollAreaused for the result list
Confirmation step
- Read-only summary of all steps grouped in
bg-mutedsections - Shows transformed display values (not raw IDs or enum strings)
Success screen
- Retains the same page header + separator
- Green check icon, success title, redirecting message
-
setTimeoutredirect after 2000 ms
Translations
- All keys added to
en.ts,ja.ts,de.ts - No hardcoded English strings — everything uses
t('...')
TypeScript
-
pnpm run buildpasses with zero errors -
pnpm run lintpasses with zero warnings - No
@ts-ignoreor untypedanyexcept where unavoidable for GraphQL enum casting
Step 3 — Applicants: implementation reference
This section documents the final, production state of the Applicants step as of March 2026. Read it before touching any of the files listed in the relevant-files table.
Relevant files
| File | Role |
|---|---|
src/features/applications/create/steps/step-3-applicants.tsx | All applicant-step UI — cards, expanded sections, AddressEditor, ProfileLinkSection, ApplicantExpandedSection |
src/features/applications/create/hooks/use-application-draft.ts | useUpdateApplicantProfile — saves profile fields + addresses to updatePublicUserProfile |
src/features/applications/create/hooks/use-public-profiles.ts | useClientById, useCreateClientPublicUserProfile, useClientsWithProfiles, useClientInvitationsForPicker |
src/features/applications/create/types.ts | ProfileAddress, DraftApplicantProfile, DraftApplicant |
src/lib/i18n/translations/en.ts (+ de.ts, ja.ts) | Translation keys under applications.create.applicants.expanded.* |
src/features/applications/detail/applicants/data/schema.ts | applicantStatuses — imported for status label/icon lookup in expanded section |
TYPE_STYLE — applicant card color schema
Each applicant type has a distinct color with both light and dark mode variants. The avatar field styles the AvatarFallback initials.
const TYPE_STYLE: Record<string, { stripe: string; text: string; avatar: string }> = {
PRIMARY: { stripe: 'border-l-indigo-500 dark:border-l-indigo-400', text: 'text-indigo-700 dark:text-indigo-300', avatar: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300' },
SECONDARY: { stripe: 'border-l-teal-500 dark:border-l-teal-400', text: 'text-teal-700 dark:text-teal-300', avatar: 'bg-teal-50 text-teal-700 dark:bg-teal-950 dark:text-teal-300' },
TERTIARY: { stripe: 'border-l-violet-500 dark:border-l-violet-400', text: 'text-violet-700 dark:text-violet-300', avatar: 'bg-violet-50 text-violet-700 dark:bg-violet-950 dark:text-violet-300' },
FOURTH: { stripe: 'border-l-amber-500 dark:border-l-amber-400', text: 'text-amber-700 dark:text-amber-300', avatar: 'bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300' },
FIFTH: { stripe: 'border-l-rose-500 dark:border-l-rose-400', text: 'text-rose-700 dark:text-rose-300', avatar: 'bg-rose-50 text-rose-700 dark:bg-rose-950 dark:text-rose-300' },
}
AvatarFallback uses cn('text-xs font-semibold', typeStyle.avatar). Never use light-only colors — always include dark: variants.
ProfileAddress — both street and line1 exist as separate DB columns
The backend PublicUserProfileAddress has two distinct address-line columns:
| Column | Meaning |
|---|---|
street | Legacy column — populated on older records |
line1 | Current column — used by all new/updated records |
line2 | Second address line — exists on both old and new records |
Never coalesce them. Always treat them independently:
- The TypeScript type
ProfileAddressintypes.tshas all three as separate optional fields. - The
AddressFormState(local edit form state) likewise hasstreet,line1,line2. - In
openAddressEditor, map each field separately:street: addr.street ?? '',line1: addr.line1 ?? ''. - In read-only display, render each as its own conditional row (see display pattern below).
- In the
saveProfileMutationmap, pass all three:street: a.street ?? undefined,line1: a.line1 ?? undefined,line2: a.line2 ?? undefined. - In the
updatePublicUserProfileGQL response andcreateClientPublicUserProfileGQL response, always requeststreet line1 line2.
GraphQL — userId must NOT be requested
createClientPublicUserProfile returns a PublicUserProfile where userId: ID! is schema-defined
non-nullable, but staff-created profiles have a null value at runtime. Never request userId in
any GQL selection for new profile creation or profile reads in this flow.
GraphQL — createClientPublicUserProfile uses clientId, not clientUserId
mutation CreateClientPublicUserProfile($clientId: ID!, $orgId: ID!, $input: PublicUserProfileInput!) {
createClientPublicUserProfile(clientId: $clientId, orgId: $orgId, input: $input) {
id firstName lastName fullName email phoneNumber profilePictureUrl bio
addresses { id addressType name street line1 line2 city state postalCode country isDefault }
}
}
The mutation input requires only clientId (not clientUserId — an earlier API name that no longer
exists). No userId derivation is needed.
AddressFormState — canonical shape
interface AddressFormState {
id?: string | null
addressType: string // 'PRIMARY' | 'POSTAL' | 'BILLING' | 'OTHER'
name: string
street: string // legacy DB column
line1: string // current DB column
line2: string
city: string
state: string
postalCode: string
country: string
isDefault: boolean
}
emptyAddress() initialises all strings to '' and isDefault to false.
AddressEditor component
Inline form rendered inside expanded applicant cards. Field order: type → label → street → line 1 → line 2 → city+state → postal+country → save/cancel. Label width is w-[88px] flex-shrink-0 throughout. Uses t('...address.street') and t('...address.line1') as separate labels (not coalesced).
Address read-only display pattern
Both display blocks (in ApplicantExpandedSection and in ProfileLinkSection) render each
address field as its own conditional row:
<div className="grid grid-cols-[72px_1fr] gap-x-2 gap-y-1 text-muted-foreground">
{addr.street && (
<>
<span className="font-medium text-[10px] uppercase tracking-wide self-start pt-0.5">
{t('applications.create.applicants.expanded.address.street')}
</span>
<span>{addr.street}</span>
</>
)}
{addr.line1 && (
<>
<span className="font-medium text-[10px] uppercase tracking-wide self-start pt-0.5">
{t('applications.create.applicants.expanded.address.line1')}
</span>
<span>{addr.line1}</span>
</>
)}
{addr.line2 && (
<>
<span className="font-medium text-[10px] uppercase tracking-wide self-start pt-0.5">
{t('applications.create.applicants.expanded.address.line2')}
</span>
<span>{addr.line2}</span>
</>
)}
{addr.city && (
<>
<span className="font-medium text-[10px] uppercase tracking-wide">
{t('applications.create.applicants.expanded.address.city')}
</span>
<span>
{[addr.city, addr.state].filter(Boolean).join(', ')}
{addr.postalCode && ` ${addr.postalCode}`}
</span>
</>
)}
{addr.country && (
<>
<span className="font-medium text-[10px] uppercase tracking-wide">
{t('applications.create.applicants.expanded.address.country')}
</span>
<span>{addr.country}</span>
</>
)}
</div>
ProfileLinkSection only has line1 + line2 in AddressFormState (no street — the create
form always writes to line1). Its display block omits the addr.street row.
ProfileLinkSection — inline, always visible
Rendered inside ApplicantExpandedSection when a client has no linked profile. Key behaviours:
- Always visible when the applicant card is expanded — no toggle button required.
- Auto-opens create form (
setShowCreateForm(true)) whenuseClientByIdresolves andprofiles.length === 0. TheuseEffectdependency array is[isLoading, profiles.length]. - Existing profiles are shown as link buttons (small secondary buttons beside each profile row).
- Create form includes: firstName (required), lastName (required), email, phone, bio, and a
full
AddressEditor-backed address list. canCreateiscfFirstName.trim() !== '' && cfLastName.trim() !== '' && !!orgId. NouserIdorclientUserIdneeded.- The single submit button is labelled with
t('...expanded.createAndLink')("Create & link profile"). - After creation,
onProfileLinkedis called with the returned profile data, which persists theprofileIdto the draft viauseUpdateApplicationDraft. - When profiles exist but the user wants to add another, a small text button reveals the create form again (and an × button collapses it back).
ApplicantExpandedSection — layout structure
Matches the detail page ApplicantViewCard layout. typeStyle is derived inside from applicant.type, statusOption from applicantStatuses.find(s => s.value === applicant.status).
border-t px-4 pb-4 pt-3 bg-muted/10
├── TOP GRID (grid grid-cols-2 gap-x-6 gap-y-4)
│ ├── LEFT col (row-span-2) — Client Membership / Invitation — read-only, 🔒 badge
│ │ └── FieldGrid: Name, Email
│ ├── RIGHT row 1 — Client Role in this Application (display-only type label, colored)
│ └── RIGHT row 2 — Client Membership Status (½-width separator above, icon + label)
├── <Separator className="my-4" />
├── PROFILE SECTION
│ ├── SectionHeader + edit/cancel toggle (hasRealProfile only)
│ ├── no-profile state → ProfileLinkSection (clients only, not invitations)
│ ├── edit mode → firstName/lastName/email/phone/bio inputs + Save button
│ └── read-only mode → FieldGrid rows for all fields
└── ADDRESSES SECTION (only if hasRealProfile)
├── <Separator className="my-4" />
├── SectionHeader + "Add address" trailing button
├── inline AddressEditor for new entry (editingAddressIndex === -1)
└── grid grid-cols-1 sm:grid-cols-2 gap-2 — per-address cards
└── text-sm card: type badge + default badge + name + edit button (icon+text) + kebab (delete)
└── address-line grid (street/line1/line2/city+state/country)
Role and Status are display-only in the wizard (no edit buttons — unlike the detail page). The type label uses typeStyle.text color. Status uses statusOption?.icon + t(statusOption.label).
saveProfileMutation — address merge pattern
Always pass the full merged address array so that saving profile text fields never accidentally wipes existing addresses (and vice-versa):
addresses: (overrides.addresses ?? profile.addresses ?? []).map((a) => ({
id: a.id ?? undefined,
addressType: a.addressType as any,
name: a.name ?? undefined,
street: a.street ?? undefined, // ← legacy column, must be passed
line1: a.line1 ?? undefined,
line2: a.line2 ?? undefined,
city: a.city ?? undefined,
state: a.state ?? undefined,
postalCode: a.postalCode ?? undefined,
country: a.country ?? undefined,
isDefault: a.isDefault ?? false,
}))
Translation keys (applications.create.applicants.expanded.*)
clientSection invitationSection profileSection
readOnly name firstName
lastName email phone
bio addresses edit
noProfileClient noProfileInvited linkOrCreateProfile
existingProfiles linkProfile notAvailable
createAndLink
address.add address.none address.default
address.type address.name address.namePlaceholder
address.street address.line1 address.line2
address.city address.state address.postalCode
address.country
address.types.primary address.types.postal
address.types.billing address.types.other
Translation label values (en):
address.street→'Street'(legacy column label)address.line1→'Line 1'(current column label — NOT "Street")address.line2→'Line 2'
Helper components
| Component | Purpose |
|---|---|
SectionHeader({ label, trailing? }) | Uppercase small-caps section divider with an optional trailing action slot |
FieldGrid({ children }) | grid-cols-[96px_1fr] label/value grid |
FieldRow({ label, value?, icon?, children? }) | Single row with an always-reserved 16 px icon slot so text aligns across rows with and without icons |
AddressEditor({ address, onSave, onCancel, isSaving }) | Self-contained address edit form used in both ProfileLinkSection and ApplicantExpandedSection |
Checklist for future changes to Step 3
-
streetandline1are always kept as separate fields — never coalesced -
userIdis never requested in any profile GQL selection -
createClientPublicUserProfileusesclientIdargument (notclientUserId) -
saveProfileMutationalways passes the merged full address array -
AddressFormStatehasstreet,line1,line2as three distinct string fields -
openAddressEditormapsstreet: addr.street ?? ''andline1: addr.line1 ?? ''separately - Read-only display renders three separate conditional rows for street / line1 / line2
-
ProfileLinkSectionauto-opens create form for clients with 0 existing profiles - Translation key
address.line1is labelled "Line 1" (not "Street") — "Street" isaddress.street
Step 4 — Documents: implementation reference (March 2026)
This section documents the current production state of the documents step.
Relevant files
| File | Role |
|---|---|
src/features/applications/create/types.ts | DraftDocumentSlot, DraftApplicationDocument type definitions |
src/features/applications/create/hooks/use-document-operations.ts | All GQL queries/mutations for slots and documents; useDocumentDefinitions |
src/features/applications/create/hooks/use-application-draft.ts | App bootstrap hook — fetches status on slots and fileSize on documents |
src/features/applications/create/steps/step-4-documents.tsx | Main step component |
src/features/applications/create/components/document-slot-card.tsx | Per-slot card with file list + upload zone + preview dialog |
src/features/applications/create/components/add-from-master-dialog.tsx | Dialog to pick from workspace DocumentDefinition master library |
Type changes — types.ts
DraftApplicationDocument now has:
fileSize?: number | null // bytes, passed on upload, fetched on load
DraftDocumentSlot now has:
status?: string | null // backend-computed: PENDING | UPLOADED | APPROVED | REJECTED | NOT_APPLICABLE
documentDefinitionId?: string | null // set when slot was created from master library
type?: string | null // type tag (e.g. "201", "ABC") — displayed alongside serial number
GQL changes — use-document-operations.ts
appDocDefsQuery selects status and type on slots and fileSize + status on documents:
edges { node { id name description category required sortOrder instructions documentDefinitionId status type
documents { id name fileName fileUrl fileType fileSize status documentTemplateId uploadedByUserId }
} }
submitDocDoc input includes fileSize: Long — always pass file.size as fileSize when calling useSubmitApplicationDocument.
createSlotDoc input includes documentDefinitionId?: string and type?: string — used when creating a slot linked to a master DocumentDefinition.
New hook: useDocumentDefinitions(workspaceId)
- Queries
documentDefinitions(workspaceId, first: 200)returning{ id, name, description, category, type, allowCustomDocuments, defaultRequired } - Used by
AddFromMasterDialogto populate the master library picker - Query key:
queryKeys.documentDefinitions(workspaceId)
mapSlot(raw) — maps status, type, and fileSize through to the typed DraftDocumentSlot / DraftApplicationDocument.
use-application-draft.ts — bootstrap GQL fields
APP_FIELDS string selects status on applicationDocumentDefinitions and fileSize on nested documents:
applicationDocumentDefinitions {
edges { node { id name ... status
documents { id name fileUrl fileType fileName fileSize status documentTemplateId }
} }
}
Step 4 layout — step-4-documents.tsx
Overall structure top-to-bottom:
- Page header — title + description + refresh icon button (top-right)
- Upload progress panel — two side-by-side cards showing required and optional upload progress
- Completeness overview — collapsible panel (open by default) containing
CompletenessTable - Separator
- Document Slots section header — label with
FileTexticon + Add buttons inline on the right - Filter bar — name search input + required/optional pill toggle + status
<select> - Slot cards — grouped by
slot.category,DocumentSlotCardrendered per slot; skeleton during load; empty state; "no matching" state when filters exclude everything WizardNavButtons canGoNext
Upload progress panel
Two side-by-side cards (grid-cols-1 sm:grid-cols-2) built from derived stats:
const requiredSlots = allSlots.filter((s) => s.required)
const optionalSlots = allSlots.filter((s) => !s.required)
const satisfiedRequired = requiredSlots.filter(
(s) => s.status === 'UPLOADED' || s.status === 'APPROVED',
).length
const satisfiedOptional = optionalSlots.filter(
(s) => s.status === 'UPLOADED' || s.status === 'APPROVED',
).length
const requiredComplete = requiredSlots.length > 0 && satisfiedRequired === requiredSlots.length
Required Documents card
- Left border accent (
border-l-4) — amber when incomplete, green when all done — gives visual priority without aggressive background coloring - Header: label (left) · status badge +
N / Mcount (right)- Badge:
REQUIRED(amber pill) →COMPLETE(green pill) on completion
- Badge:
- Progress bar: light colored track (
bg-amber-200/bg-green-200) with saturated fill (bg-amber-500/bg-green-500),transition-all duration-500 - Sub-text:
X still neededorAll required documents uploaded ✔
Optional Documents card
- Same
bg-muted/30plain border as required — consistent base - Header: label (left) ·
N / Mcount (right) — no badge (optional, no urgency signal needed) - Progress bar:
bg-primary/15track withbg-primaryfill - Sub-text:
X not yet uploadedorAll optional documents uploaded ✔
CompletenessTable component (internal, step-4-documents.tsx)
- All rows are clickable (expand/collapse) — chevron
▶/▼always shown in# Typecolumn regardless of document count - Columns:
# Type(with chevron — stacked serial number + type tag) · Name + category tag · Required · Status badge · Files count · Go-to↗button # Typecolumn header: labelledoverviewTypetranslation key (value:"# Type") — communicates serial number + type tag together# Typecell: expand chevron + stacked pair —01/02number (semibold) withslot.typein a dimmer smaller line beneath (only whenslot.typeis set)- Status badge: icon + label always shown for all statuses including PENDING
- Go-to button (
ArrowUpRight): scrolls to the targetDocumentSlotCardAND triggers a 2-second highlight animation on it. Usese.stopPropagation()to prevent row expand toggle. - Receives filter props so visible rows always stay in sync with the slot card list below
allSlots.indexOf(slot)used for global serial number (stays consistent even when filtered)
Expanded sub-rows
- Toggled by clicking any row
- When no documents: shows centered
"No files uploaded yet."message - When documents exist: compact one-line-per-document list:
[FileText icon]· file name (truncated) · type badge (PDF/DOCX/etc.) · status icon only (no label — usestitletooltip) ·👁preview button (iffileUrlexists)
- Preview opens
FilePreviewDialoginline from the overview table (exported fromdocument-slot-card.tsx)
Highlight-on-navigate
const [highlightedSlotId, setHighlightedSlotId] = useState<string | null>(null)
const handleScrollTo = useCallback((slotId: string) => {
setHighlightedSlotId(slotId)
setTimeout(() => setHighlightedSlotId(null), 2000)
}, [])
- Highlighted card:
ring-2 ring-primary/70 ring-offset-2 shadow-md+ numbered circle changes tobg-primary text-primary-foreground - Both fade out via
transition-all duration-700
AddCustomSlotDialog component (internal, step-4-documents.tsx)
- Dialog with: name input, type input (optional, e.g. "201", "ABC"), instructions input (optional), required checkbox
- Calls
useCreateDocumentSlotwithsortOrder: 999andtypefor custom slots - On success: calls
onCreated(slot)→ parent appends to localdocumentSlotsstate
AddFromMasterDialog — add-from-master-dialog.tsx
- Fetches workspace
DocumentDefinitionlist viauseDocumentDefinitions(activeWorkspace?.id) - Searchable, categorized list; already-added definitions shown as disabled with "Already added" badge
- List rows show
def.typeas monospace prefix before the definition name (when present) - Multi-select checkboxes; "Add selected" button calls
useCreateDocumentSlotonce per definition, passingdocumentDefinitionIdandtype: def.type - Props:
applicationId,alreadyAddedDefinitionIds: Set<string>,onAdded(newSlots: DraftDocumentSlot[])
DocumentSlotCard — document-slot-card.tsx
Props
export interface DocumentSlotProps {
slot: DraftDocumentSlot
slotIndex: number // 0-based; used for numbered badge (01, 02, 03...)
applicationId: string
onSlotsChanged: () => void // triggers refetch in parent
highlighted?: boolean // when true: primary ring + primary numbered circle for 2s
}
Card layout
┌─────────────────────────────────────────────────────────────┐
│ [01 ] Slot name [category] [required] [STATUS] │ ← CardHeader
│ [ABC ] │ (number + type stacked)
├─────────────────────────────────────────────────────────────┤ ← Separator
│ LEFT: file list │ RIGHT: UploadZone (always visible) │ ← CardContent
│ • template files section │ │
│ • uploaded files section │ │
│ • "no files" placeholder │ │
└─────────────────────────────────────────────────────────────┘
- Header: slot name on left, badges (category → required → status) pinned right
- Number + type badge: stacked rounded-lg badge — bold
01/02number on top, monospaceslot.typein a smaller line below (only shown ifslot.typeis set); transitions to primary color whenhighlighted - Body:
grid-cols-1 gap-4 lg:grid-cols-[1fr_260px] - Card border turns destructive-tinted when
slotStatus === 'REJECTED' - Card gets
ring-2 ring-primary/70 ring-offset-2 shadow-mdwhenhighlighted - Each card has
id={slot-${slot.id}}for the completeness table scroll-to feature
FilePreviewDialog — exported (use from overview table too)
export function FilePreviewDialog({ fileUrl, fileType, fileName, token, open, onClose })
- Pre-signed S3 URLs (
x-amz-signature) used directly; others fetched with Bearer token → blob URL - Images →
<img>· PDFs →<iframe>· other → download fallback card - Uses shadcn's built-in
DialogContentclose button — do NOT add a manual X button (causes duplicate close buttons) - Blob URL revoked on close via
useEffectcleanup
Status config — DOC_STATUS_CONFIG
const DOC_STATUS_CONFIG = {
PENDING: { icon: Clock, label: '...status.pending', classes: 'text-amber-600 bg-amber-50 border-amber-200' },
UPLOADED: { icon: CheckCircle, label: '...status.uploaded', classes: 'text-green-600 bg-green-50 border-green-200' },
APPROVED: { icon: CheckCircle, label: '...status.approved', classes: 'text-emerald-700 bg-emerald-50 border-emerald-200' },
REJECTED: { icon: AlertCircle, label: '...status.rejected', classes: 'text-destructive bg-destructive/5 border-destructive/20' },
NOT_APPLICABLE: { icon: FileText, label: '...status.notApplicable', classes: 'text-muted-foreground bg-muted border-muted' },
}
Unknown status values fall back to PENDING.
File upload flow
UploadZonecallshandleFiles(files: File[])- For each file:
uploadFile(file, { workspaceId, context: 'APP_DOCUMENT', entityId: applicationId })viauseFileUpload - On success:
submitDoc({ input: { applicationDocumentDefinitionId: slot.id, name, fileUrl, fileType, fileName, fileSize: file.size } }) onSlotsChanged()→ parentrefetch()- Upload progress tracked in local
uploadStatesstate, cleared after 2s on success
Translation keys (under applications.create.documents.*)
status.pending / uploaded / approved / rejected / notApplicable
overview (completeness panel title)
detailsSection ("Document Slots" section header)
overviewType ("# Type" — completeness table first column header)
overviewName / overviewStatus / overviewFiles (completeness table column headers)
scrollToSlot (go-to button tooltip)
filterByName / filterAll / filterRequired / filterOptional / filterStatus
noMatchingSlots (empty filter result)
addCustomSlot / slotName / slotNamePlaceholder / slotType / slotTypePlaceholder / markRequired
addFromMaster / addFromMasterTitle
preview.action / preview.unavailable / preview.notSupported
templateFile / noFiles / uploadedFiles / templateFiles
progress.requiredLabel / progress.requiredDone / progress.requiredPending
progress.badgeDone / progress.badgePending
progress.optionalLabel / progress.optionalDone / progress.optionalPending
Query keys used
queryKeys.applicationDocumentDefinitions(applicationId) // slot list
queryKeys.documentDefinitions(workspaceId) // master library
Both must be defined in src/lib/query-keys.ts.
⚠️ Open / not yet finished
- Delete slot — UI not yet added to
DocumentSlotCard - Edit slot (rename, change instructions, toggle required) — no edit dialog yet
- Reorder slots (drag-and-drop or up/down arrows for
sortOrder) - "Not applicable" action — allow marking a slot as
NOT_APPLICABLEwithout uploading -
updateApplicationDocumentDefinitionanddeleteApplicationDocumentDefinitionmutations exist inuse-document-operations.tsbut are not wired to UI -
AddFromMasterDialog— no per-slot loading indicator during bulk creation - Review step (
step-5-review) — implemented, readsslot.statuscorrectly
Checklist for future changes to Step 4
- Always pass
fileSize: file.sizewhen callingsubmitDocinhandleFiles -
slotIndexpassed toDocumentSlotCardmust come fromallSlots.indexOf(slot)(unfiltered), notfilteredSlots - Each
DocumentSlotCardmust keepid={slot-${slot.id}}for the completeness table scroll-to -
FilePreviewDialogis exported — import fromdocument-slot-card.tsxwherever needed - Never add a manual X button inside
DialogContent— shadcn already renders one - Pre-signed S3 URLs must be handled without auth headers (detected by
x-amz-signaturein query string) - Blob URLs created in
FilePreviewDialogmust be revoked on dialog close -
queryKeys.documentDefinitionsmust exist inquery-keys.tsbefore usinguseDocumentDefinitions - Progress bar tracks must use a light tinted color matching the fill (e.g.
bg-amber-200track +bg-amber-500fill) — not opaque black/grey - Required card uses left border accent (
border-l-4) for priority signal; card background stays neutral (bg-muted/30) -
DOC_STATUS_CONFIGis the single source of truth for status labels/colors — do not duplicate instep-4-documents.tsx - Slot number+type badge: stacked rounded-lg (not rounded-full circle) — number bold on top,
slot.typemonospace smaller below -
overviewTypetranslation key must exist in all three language files (value:"# Type"in en) -
typefield must be passed increateSlotDocinput (from bothAddCustomSlotDialogandAddFromMasterDialog)
Document Definitions feature (src/features/document-definitions/)
Standalone management feature for workspace-level
DocumentDefinitionrecords (separate from the application wizard Step 4).
type field
DocumentDefinition has a type?: string field (e.g. "201", "ABC", "PASSPORT") that serves as a short type tag.
Files updated:
data/schema.ts—documentDefinitionSchemaincludestype: z.string().optional()data/use-document-definitions.ts—RawDefinitionhastype?;docDefQueryanddocDefsQueryGQL selecttype;createDocDefMutationandupdateDocDefMutationinput TS types includetype?: stringand GQL return selectstype;transformDefinitionmapstype: raw.type ?? undefinedcomponents/list/document-definitions-columns.tsx—typecolumn rendered as monospacebg-mutedbadge, displayed beforecategorycomponents/actions/document-definition-dialog.tsx—formDataincludestype; form has a "Type" input field (below category);handleSubmitpassestype: formData.type || undefineddetail/components/document-definition-about.tsx— displaystypeas monospace badge aftercategory- Translation key
documentDefinitions.columns.typeadded toen.ts,ja.ts,de.ts
Step 5 — Review & Submit: implementation reference (March 2026)
Full read-only confirmation step. Renders all wizard data before final submission.
Relevant file
| File | Role |
|---|---|
src/features/applications/create/steps/step-5-review.tsx | Full review + submit component |
Layout structure
Step5Review
├── Header (h3 + description)
├── <Separator />
├── [Warning banner] — amber box listing any unsatisfied required document slots
├── ReviewSection — Template & Type
│ └── Two side-by-side cards (sm:grid-cols-2)
│ ├── Application Type card — colored type badge (FINANCIAL=green, TAX=amber, PAYROLL=blue)
│ └── Application Template card — name + description + doc count; "No template" italic fallback
├── ReviewSection — Basic Info
│ └── Title + optional description in label/value rows
├── ReviewSection — Applicants (sm:grid-cols-2 grid of ApplicantCard)
│ └── ApplicantCard (expandable)
│ ├── Header row: initials avatar · name · role badge · [Invited] amber badge · #N
│ └── Expanded detail (border-t bg-background)
│ ├── Identity source section (client or invitation fields)
│ ├── Profile section (email, phone, bio)
│ └── Addresses section (type badge + formatted address line per address)
└── ReviewSection — Documents (DocumentsReview)
├── Progress cards (sm:grid-cols-2)
│ ├── Required card (border-l-4 amber/green + progress bar + badge)
│ └── Optional card (primary progress bar)
└── Expandable completeness table
├── Columns: # Type · Name+category · Required · Status icon · Files
└── Expanded sub-rows: per-file list (icon · name · ext badge · status icon · 👁 preview)
slotSatisfied — authoritative satisfaction check
function slotSatisfied(slot: DraftDocumentSlot): boolean {
return slot.status === 'UPLOADED' || slot.status === 'APPROVED'
}
NEVER add || slot.documents.length > 0 as a fallback — this causes the review progress to
disagree with step 4. Backend slot.status is the single source of truth.
APP_FIELDS bootstrap GQL — includes type on document slots
The APP_FIELDS constant in use-application-draft.ts selects type on
applicationDocumentDefinitions nodes:
documents { id name description category required sortOrder instructions documentDefinitionId status type
documents { id name fileName fileUrl fileType fileSize status documentTemplateId uploadedByUserId }
}
This is required for slot.type to be available in the review step without first visiting step 4.
Do not remove type from this selection.
ApplicantCard — key behaviours
- Expandable: click anywhere on the header row to toggle the detail panel
- Invited badge: rendered when
a.clientInvitationis set anda.clientisundefined/null— amber border/bg, text "Invited" - Identity section: shows name + email from the
clientorclientInvitationsource - Profile section: shows
profile.email,profile.phoneNumber,profile.bioif present - Addresses:
profile.addressesrendered one per row withaddressTypebadge +formatAddress()(coalescesstreet || line1, line2, city, state, postalCode, country) - Long values (emails, addresses): use
break-all— nevertruncate— so full content is always visible
DocumentsReview + DocumentSlotRow — key behaviours
- Progress bars use
slotSatisfied()— backend status only (see above) - Table rows (
DocumentSlotRow) are expandable (chevron in# Typecell) - Expanded sub-rows: one line per
slot.documentsentry- FileText icon ·
doc.fileName || doc.name(break-all) · ext badge (fromfileType) · status icon withtitletooltip · 👁 eye button ifdoc.fileUrlset
- FileText icon ·
- Preview: clicking eye sets local
previewDocstate → renders<FilePreviewDialog>tokencomes fromuseAuth().user?.access_token(passed down fromStep5Review)- Import
FilePreviewDialogfrom../components/document-slot-card
DOC_STATUS_CFG — local copy in review step
The review step owns a local DOC_STATUS_CFG constant (not imported from document-slot-card.tsx)
so the review step stays self-contained:
const DOC_STATUS_CFG = {
PENDING: { Icon: Clock, classes: 'text-amber-500', label: 'Pending' },
UPLOADED: { Icon: CheckCircle2, classes: 'text-green-500', label: 'Uploaded' },
APPROVED: { Icon: CheckCircle2, classes: 'text-emerald-600', label: 'Approved' },
REJECTED: { Icon: AlertCircle, classes: 'text-destructive', label: 'Rejected' },
NOT_APPLICABLE: { Icon: FileText, classes: 'text-muted-foreground', label: 'N/A' },
} as const
Translation keys used
applications.create.review.edit ← 'Edit' button on each ReviewSection header
applications.create.review.basicInfo ← 'Application Details'
applications.create.review.missingDocuments.title
applications.create.review.missingDocuments.fix
applications.create.steps.review.title / .description
applications.create.steps.template.title
applications.create.steps.applicants.title
applications.create.steps.documents.title
applications.create.template.noTemplate / .documents
applications.create.documents.progress.requiredLabel / badgeDone / badgePending
applications.create.documents.progress.optionalLabel
applications.create.documents.overviewType / overviewName / overviewStatus / overviewFiles / required / filterOptional
applications.create.submitted.title / .description / .viewApplications
applications.create.submit
applications.columns.type / title / description / template
applications.applicants.none
Checklist for future changes to Step 5
-
slotSatisfiedmust ONLY checkslot.status === 'UPLOADED' || 'APPROVED'— no document-count fallback -
APP_FIELDSinuse-application-draft.tsmust selecttypeon document slot nodes -
FilePreviewDialogimported from../components/document-slot-card(not re-implemented) -
tokensourced fromuseAuth().user?.access_tokenand passed toDocumentsReview→DocumentSlotRow - Long text fields (email, addresses, file names) use
break-all— nevertruncate - Invited badge shown when
a.clientInvitationis present anda.clientis absent -
SubmitSuccessscreen navigates to/o/$orgSlug/applicationsafter submission - All translation keys must exist in
en.ts,ja.ts,de.ts
Document Definitions feature (src/features/document-definitions/)
Standalone management feature for workspace-level
DocumentDefinitionrecords (separate from the application wizard Step 4).
type field
DocumentDefinition has a type?: string field (e.g. "201", "ABC", "PASSPORT") that serves as a short type tag.
Files updated:
data/schema.ts—documentDefinitionSchemaincludestype: z.string().optional()data/use-document-definitions.ts—RawDefinitionhastype?;docDefQueryanddocDefsQueryGQL selecttype;createDocDefMutationandupdateDocDefMutationinput TS types includetype?: stringand GQL return selectstype;transformDefinitionmapstype: raw.type ?? undefinedcomponents/list/document-definitions-columns.tsx—typecolumn rendered as monospacebg-mutedbadge, displayed beforecategorycomponents/actions/document-definition-dialog.tsx—formDataincludestype; form has a "Type" input field (below category);handleSubmitpassestype: formData.type || undefineddetail/components/document-definition-about.tsx— displaystypeas monospace badge aftercategory- Translation key
documentDefinitions.columns.typeadded toen.ts,ja.ts,de.ts
Checklist for future changes to document-definitions
-
typemust be included in bothdocDefQueryanddocDefsQueryGQL selections - Both mutation input TS types (
createDocDefMutation,updateDocDefMutation) must includetype?: string -
transformDefinitionmust maptype: raw.type ?? undefined -
documentDefinitions.columns.typetranslation key must exist in all three language files