Skip to main content

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:

FileRole
src/features/applications/components/actions/application-add-page.tsxPage wrapper (BreadcrumbBar + Main)
src/features/applications/components/actions/application-add/application-add-new.tsxMain wizard component
src/features/applications/components/actions/application-add/applicant-selector.tsxPopup/dialog selector
src/routes/_authenticated/o/$orgSlug/applications/add.tsxRoute 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 around BreadcrumbBar + Main.
  • Always pass fixed fluid to <Main>. Never add extra className props to <Main>.
  • BreadcrumbBar gets className="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 zodResolver with mode: '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
}
}
const nextStep = () => {
if (validateCurrentStep() && currentStep < steps.length) {
setCurrentStep(currentStep + 1)
}
}

const prevStep = () => {
if (currentStep > 1) setCurrentStep(currentStep - 1)
}

Submission

  • Guard onSubmit so it only fires on the last step.
  • After success set isSuccess = true and 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 — never max-w-* mx-auto
  • Header uses h2 text-2xl font-bold tracking-tight — no h1, no responsive size variants
  • <Separator className="my-4 lg:my-6" /> after the header

Step sidebar

  • lg:w-56 sidebar is on the left; collapses to top-stack on mobile
  • Completed steps: filled primary circle + <Check> icon
  • Current step: outlined primary circle + bg-muted row background
  • Future steps: muted circle, muted text

Validation & navigation

  • mode: 'onChange' on useForm for live validation
  • validateCurrentStep() returns false when the current step is incomplete
  • Next button disabled={!validateCurrentStep()}
  • Previous button disabled={currentStep === 1}
  • Submit button only appears on the last step; disabled={isPending}
  • onSubmit guards against being called before the last step
  • Dialog closes automatically after selection (setOpen(false))
  • Search input filters by all relevant fields
  • Empty state shown when no results match
  • ScrollArea used for the result list

Confirmation step

  • Read-only summary of all steps grouped in bg-muted sections
  • 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
  • setTimeout redirect after 2000 ms

Translations

  • All keys added to en.ts, ja.ts, de.ts
  • No hardcoded English strings — everything uses t('...')

TypeScript

  • pnpm run build passes with zero errors
  • pnpm run lint passes with zero warnings
  • No @ts-ignore or untyped any except 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

FileRole
src/features/applications/create/steps/step-3-applicants.tsxAll applicant-step UI — cards, expanded sections, AddressEditor, ProfileLinkSection, ApplicantExpandedSection
src/features/applications/create/hooks/use-application-draft.tsuseUpdateApplicantProfile — saves profile fields + addresses to updatePublicUserProfile
src/features/applications/create/hooks/use-public-profiles.tsuseClientById, useCreateClientPublicUserProfile, useClientsWithProfiles, useClientInvitationsForPicker
src/features/applications/create/types.tsProfileAddress, 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.tsapplicantStatuses — 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:

ColumnMeaning
streetLegacy column — populated on older records
line1Current column — used by all new/updated records
line2Second address line — exists on both old and new records

Never coalesce them. Always treat them independently:

  • The TypeScript type ProfileAddress in types.ts has all three as separate optional fields.
  • The AddressFormState (local edit form state) likewise has street, 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 saveProfileMutation map, pass all three: street: a.street ?? undefined, line1: a.line1 ?? undefined, line2: a.line2 ?? undefined.
  • In the updatePublicUserProfile GQL response and createClientPublicUserProfile GQL response, always request street 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 → streetline 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.

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)) when useClientById resolves and profiles.length === 0. The useEffect dependency 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.
  • canCreate is cfFirstName.trim() !== '' && cfLastName.trim() !== '' && !!orgId. No userId or clientUserId needed.
  • The single submit button is labelled with t('...expanded.createAndLink') ("Create & link profile").
  • After creation, onProfileLinked is called with the returned profile data, which persists the profileId to the draft via useUpdateApplicationDraft.
  • 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

ComponentPurpose
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

  • street and line1 are always kept as separate fields — never coalesced
  • userId is never requested in any profile GQL selection
  • createClientPublicUserProfile uses clientId argument (not clientUserId)
  • saveProfileMutation always passes the merged full address array
  • AddressFormState has street, line1, line2 as three distinct string fields
  • openAddressEditor maps street: addr.street ?? '' and line1: addr.line1 ?? '' separately
  • Read-only display renders three separate conditional rows for street / line1 / line2
  • ProfileLinkSection auto-opens create form for clients with 0 existing profiles
  • Translation key address.line1 is labelled "Line 1" (not "Street") — "Street" is address.street

Step 4 — Documents: implementation reference (March 2026)

This section documents the current production state of the documents step.

Relevant files

FileRole
src/features/applications/create/types.tsDraftDocumentSlot, DraftApplicationDocument type definitions
src/features/applications/create/hooks/use-document-operations.tsAll GQL queries/mutations for slots and documents; useDocumentDefinitions
src/features/applications/create/hooks/use-application-draft.tsApp bootstrap hook — fetches status on slots and fileSize on documents
src/features/applications/create/steps/step-4-documents.tsxMain step component
src/features/applications/create/components/document-slot-card.tsxPer-slot card with file list + upload zone + preview dialog
src/features/applications/create/components/add-from-master-dialog.tsxDialog 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 AddFromMasterDialog to 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:

  1. Page header — title + description + refresh icon button (top-right)
  2. Upload progress panel — two side-by-side cards showing required and optional upload progress
  3. Completeness overview — collapsible panel (open by default) containing CompletenessTable
  4. Separator
  5. Document Slots section header — label with FileText icon + Add buttons inline on the right
  6. Filter bar — name search input + required/optional pill toggle + status <select>
  7. Slot cards — grouped by slot.category, DocumentSlotCard rendered per slot; skeleton during load; empty state; "no matching" state when filters exclude everything
  8. 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 / M count (right)
    • Badge: REQUIRED (amber pill) → COMPLETE (green pill) on completion
  • 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 needed or All required documents uploaded ✔

Optional Documents card

  • Same bg-muted/30 plain border as required — consistent base
  • Header: label (left) · N / M count (right) — no badge (optional, no urgency signal needed)
  • Progress bar: bg-primary/15 track with bg-primary fill
  • Sub-text: X not yet uploaded or All optional documents uploaded ✔

CompletenessTable component (internal, step-4-documents.tsx)

  • All rows are clickable (expand/collapse) — chevron / always shown in # Type column regardless of document count
  • Columns: # Type (with chevron — stacked serial number + type tag) · Name + category tag · Required · Status badge · Files count · Go-to button
  • # Type column header: labelled overviewType translation key (value: "# Type") — communicates serial number + type tag together
  • # Type cell: expand chevron + stacked pair — 01/02 number (semibold) with slot.type in a dimmer smaller line beneath (only when slot.type is set)
  • Status badge: icon + label always shown for all statuses including PENDING
  • Go-to button (ArrowUpRight): scrolls to the target DocumentSlotCard AND triggers a 2-second highlight animation on it. Uses e.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 — uses title tooltip) · 👁 preview button (if fileUrl exists)
  • Preview opens FilePreviewDialog inline from the overview table (exported from document-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 to bg-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 useCreateDocumentSlot with sortOrder: 999 and type for custom slots
  • On success: calls onCreated(slot) → parent appends to local documentSlots state

AddFromMasterDialogadd-from-master-dialog.tsx

  • Fetches workspace DocumentDefinition list via useDocumentDefinitions(activeWorkspace?.id)
  • Searchable, categorized list; already-added definitions shown as disabled with "Already added" badge
  • List rows show def.type as monospace prefix before the definition name (when present)
  • Multi-select checkboxes; "Add selected" button calls useCreateDocumentSlot once per definition, passing documentDefinitionId and type: def.type
  • Props: applicationId, alreadyAddedDefinitionIds: Set<string>, onAdded(newSlots: DraftDocumentSlot[])

DocumentSlotCarddocument-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/02 number on top, monospace slot.type in a smaller line below (only shown if slot.type is set); transitions to primary color when highlighted
  • 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-md when highlighted
  • Each card has id={slot-${slot.id}} for the completeness table scroll-to feature

FilePreviewDialogexported (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 DialogContent close button — do NOT add a manual X button (causes duplicate close buttons)
  • Blob URL revoked on close via useEffect cleanup

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

  1. UploadZone calls handleFiles(files: File[])
  2. For each file: uploadFile(file, { workspaceId, context: 'APP_DOCUMENT', entityId: applicationId }) via useFileUpload
  3. On success: submitDoc({ input: { applicationDocumentDefinitionId: slot.id, name, fileUrl, fileType, fileName, fileSize: file.size } })
  4. onSlotsChanged() → parent refetch()
  5. Upload progress tracked in local uploadStates state, 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_APPLICABLE without uploading
  • updateApplicationDocumentDefinition and deleteApplicationDocumentDefinition mutations exist in use-document-operations.ts but are not wired to UI
  • AddFromMasterDialog — no per-slot loading indicator during bulk creation
  • Review step (step-5-review) — implemented, reads slot.status correctly

Checklist for future changes to Step 4

  • Always pass fileSize: file.size when calling submitDoc in handleFiles
  • slotIndex passed to DocumentSlotCard must come from allSlots.indexOf(slot) (unfiltered), not filteredSlots
  • Each DocumentSlotCard must keep id={slot-${slot.id}} for the completeness table scroll-to
  • FilePreviewDialog is exported — import from document-slot-card.tsx wherever 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-signature in query string)
  • Blob URLs created in FilePreviewDialog must be revoked on dialog close
  • queryKeys.documentDefinitions must exist in query-keys.ts before using useDocumentDefinitions
  • Progress bar tracks must use a light tinted color matching the fill (e.g. bg-amber-200 track + bg-amber-500 fill) — 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_CONFIG is the single source of truth for status labels/colors — do not duplicate in step-4-documents.tsx
  • Slot number+type badge: stacked rounded-lg (not rounded-full circle) — number bold on top, slot.type monospace smaller below
  • overviewType translation key must exist in all three language files (value: "# Type" in en)
  • type field must be passed in createSlotDoc input (from both AddCustomSlotDialog and AddFromMasterDialog)

Document Definitions feature (src/features/document-definitions/)

Standalone management feature for workspace-level DocumentDefinition records (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.tsdocumentDefinitionSchema includes type: z.string().optional()
  • data/use-document-definitions.tsRawDefinition has type?; docDefQuery and docDefsQuery GQL select type; createDocDefMutation and updateDocDefMutation input TS types include type?: string and GQL return selects type; transformDefinition maps type: raw.type ?? undefined
  • components/list/document-definitions-columns.tsxtype column rendered as monospace bg-muted badge, displayed before category
  • components/actions/document-definition-dialog.tsxformData includes type; form has a "Type" input field (below category); handleSubmit passes type: formData.type || undefined
  • detail/components/document-definition-about.tsx — displays type as monospace badge after category
  • Translation key documentDefinitions.columns.type added to en.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

FileRole
src/features/applications/create/steps/step-5-review.tsxFull 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.clientInvitation is set and a.client is undefined/null — amber border/bg, text "Invited"
  • Identity section: shows name + email from the client or clientInvitation source
  • Profile section: shows profile.email, profile.phoneNumber, profile.bio if present
  • Addresses: profile.addresses rendered one per row with addressType badge + formatAddress() (coalesces street || line1, line2, city, state, postalCode, country)
  • Long values (emails, addresses): use break-all — never truncate — 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 # Type cell)
  • Expanded sub-rows: one line per slot.documents entry
    • FileText icon · doc.fileName || doc.name (break-all) · ext badge (from fileType) · status icon with title tooltip · 👁 eye button if doc.fileUrl set
  • Preview: clicking eye sets local previewDoc state → renders <FilePreviewDialog>
    • token comes from useAuth().user?.access_token (passed down from Step5Review)
    • Import FilePreviewDialog from ../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

  • slotSatisfied must ONLY check slot.status === 'UPLOADED' || 'APPROVED' — no document-count fallback
  • APP_FIELDS in use-application-draft.ts must select type on document slot nodes
  • FilePreviewDialog imported from ../components/document-slot-card (not re-implemented)
  • token sourced from useAuth().user?.access_token and passed to DocumentsReviewDocumentSlotRow
  • Long text fields (email, addresses, file names) use break-all — never truncate
  • Invited badge shown when a.clientInvitation is present and a.client is absent
  • SubmitSuccess screen navigates to /o/$orgSlug/applications after 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 DocumentDefinition records (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.tsdocumentDefinitionSchema includes type: z.string().optional()
  • data/use-document-definitions.tsRawDefinition has type?; docDefQuery and docDefsQuery GQL select type; createDocDefMutation and updateDocDefMutation input TS types include type?: string and GQL return selects type; transformDefinition maps type: raw.type ?? undefined
  • components/list/document-definitions-columns.tsxtype column rendered as monospace bg-muted badge, displayed before category
  • components/actions/document-definition-dialog.tsxformData includes type; form has a "Type" input field (below category); handleSubmit passes type: formData.type || undefined
  • detail/components/document-definition-about.tsx — displays type as monospace badge after category
  • Translation key documentDefinitions.columns.type added to en.ts, ja.ts, de.ts

Checklist for future changes to document-definitions

  • type must be included in both docDefQuery and docDefsQuery GQL selections
  • Both mutation input TS types (createDocDefMutation, updateDocDefMutation) must include type?: string
  • transformDefinition must map type: raw.type ?? undefined
  • documentDefinitions.columns.type translation key must exist in all three language files