Application Detail Page
Overview
The application detail page provides a full view of a single application with sidebar navigation between sub-pages. Route: /_authenticated/o/$orgSlug/applications/$applicationId.
It is role-gated via useApplicationPermissions() — different roles see different capabilities on each sub-page.
File Structure
src/features/applications/detail/
├── index.tsx # Main layout — breadcrumb, header, sidebar nav, Outlet
├── components/
│ └── application-about.tsx # About tab (read-only fields from context)
├── overview/
│ └── index.tsx # Overview tab — editable info, status change, applicant summary, timestamps
├── applicants/
│ ├── index.tsx # Applicants tab — list of ApplicantViewCard
│ ├── applicant-view-card.tsx # Individual applicant card (expandable)
│ └── data/
│ ├── schema.ts # Applicant schema types
│ └── use-applications-applicants.ts # GQL hook for applicants
├── documents/
│ └── index.tsx # Documents tab — progress cards, slot list, add custom/master
└── decision/
└── index.tsx # Decision tab — summary stats, status banner, decision panel
Routes:
src/routes/_authenticated/o/$orgSlug/applications/$applicationId/
├── index.tsx # Detail layout route (redirects to overview)
├── route.tsx # Route config — mounts ApplicationDetail component
├── about/index.tsx
├── applicants/index.tsx
├── documents/index.tsx
└── decision/index.tsx
ApplicationProvider & Context
src/context/application-provider.tsx — wraps the entire detail page.
// Provided at detail/index.tsx level
<ApplicationProvider application={application}>
<Outlet />
</ApplicationProvider>
// Consumed in every sub-page
const { application } = useApplicationContext()
if (!application) return null
Rule: Sub-pages get application from context — never re-fetch it. Only detail/index.tsx fetches via useApplication(applicationId).
Application Schema (data/schema.ts)
type Application = {
id: string
title: string
description?: string
status: SelectOption // transformed from status.internal
type: SelectOption // transformed from GraphQL type enum
slug: string
createdAt: Date
updatedAt: Date
workspaceId: string
orgId?: string
template?: { id: string; name: string; description?: string }
applicants?: { id: string; firstName: string; lastName: string; email: string }[]
}
Status values (ApplicationStatusEnum): DRAFT, PENDING, SUBMITTED, IN_REVIEW, WAITING_FOR_DOCUMENTS, APPROVED, REJECTED, ACTIVE, INACTIVE, CLOSED, SUSPENDED, DELETED, CANCELLED, ARCHIVED
Type values (ApplicationType): FINANCIAL, TAX, PAYROLL
Key Hooks
useApplication(id: string)
Fetches full application data. Used only in detail/index.tsx.
useUpdateApplication()
Returns { updateApplicationData(id, input), isLoading, isError }.
Input shape:
{
title: string
description?: string
status?: { internal: string } // pass status value string here
}
Invalidates: queryKeys.applications(), queryKeys.application(id).
useApplicationApplicants(applicationId: string)
Returns { applicants, isLoading, isError } — detailed applicant data for the applicants sub-page.
useApplicationDocumentSlots(applicationId: string)
Returns { data: DraftDocumentSlot[], isLoading, isError, refetch, isFetching } — document slots for the documents sub-page.
Permissions System
src/features/applications/hooks/use-application-permissions.ts
const { can } = useApplicationPermissions()
// Available permissions:
can('VIEW') // All authenticated users
can('EDIT_INFO') // OWNER, ADMIN, MANAGER
can('EDIT_APPLICANTS') // OWNER, ADMIN, MANAGER
can('EDIT_APPLICANT_STATUS')// CLIENT only — applicant membership status
can('MANAGE_DOCUMENTS') // OWNER, ADMIN, MANAGER
can('UPLOAD_DOCUMENTS') // OWNER, ADMIN, MANAGER, MEMBER, CLIENT
can('DECIDE') // OWNER, ADMIN, MANAGER
can('VIEW_DECISION') // OWNER, ADMIN, MANAGER, MEMBER
can('MANAGE_PERMISSIONS') // OWNER, ADMIN
Pattern: Gate UI elements with {can('DECIDE') && <Button>...}.
Important: EDIT_APPLICANT_STATUS is intentionally restricted to CLIENT only. OWNER/ADMIN/MANAGER see a read-only lock on the status field. Only the client themselves can change their own applicant status.
Sub-Pages
Overview (detail/overview/index.tsx)
- Editable title/description (gated:
can('EDIT_INFO')) - Status change dropdown showing valid transitions (gated:
can('DECIDE')) - Status/type badge cards
- Template info card
- Applicants summary (avatars + names)
- Timestamps (createdAt, updatedAt)
Status transitions map (which statuses can transition to which):
const STATUS_TRANSITIONS: Record<string, string[]> = {
DRAFT: ['PENDING'],
PENDING: ['IN_REVIEW', 'CANCELLED'],
SUBMITTED: ['IN_REVIEW', 'CANCELLED'],
IN_REVIEW: ['APPROVED', 'REJECTED', 'WAITING_FOR_DOCUMENTS', 'CANCELLED'],
WAITING_FOR_DOCUMENTS: ['IN_REVIEW', 'CANCELLED'],
APPROVED: ['IN_REVIEW', 'ARCHIVED'],
REJECTED: ['IN_REVIEW', 'ARCHIVED'],
ACTIVE: ['INACTIVE', 'ARCHIVED'],
INACTIVE: ['ACTIVE', 'ARCHIVED'],
CLOSED: ['IN_REVIEW'],
SUSPENDED: ['IN_REVIEW', 'CANCELLED'],
}
About (detail/components/application-about.tsx)
Read-only display of: title, description, type badge, status badge, createdAt, template name.
Uses ContentSection wrapper from src/features/settings/components/content-section.tsx.
Applicants (detail/applicants/index.tsx)
Lists applicants using ApplicantViewCard — expandable cards showing profile details.
Data from useApplicationApplicants(applicationId).
Applicants are sorted by type order (PRIMARY first, then SECONDARY, etc.) using applicantTypes index from data/schema.ts.
ApplicantViewCard
src/features/applications/detail/applicants/applicant-view-card.tsx
Expandable card for a single applicant. Click the card header to expand/collapse.
Color Schema (TYPE_STYLE)
Each applicant type has a distinct color with both light and dark mode variants:
const TYPE_STYLE = {
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' },
}
Layout Primitives
SectionHeader— section title row with optionaltrailingslot (used for Edit/Cancel buttons or kebab menus)FieldGrid—grid-cols-[96px_1fr]two-column label/value layout. Labels are uppercasetext-[10px].FieldRow— single label/value pair inside aFieldGrid. Values usetext-sm.AddressBlock— displays a single address with edit (inline button) and delete (kebab menu). Values usetext-sm.AddressEditor— inline address form. Cancel + Save both at the bottom together.
Expanded Card Layout
┌─ Client Membership (row-span-2) ─┬─ Client Role in this Application ─┐
│ Lock icon + "Read only" │ Edit button │
│ (or kebab menu if invited) │ Type label (colored) │
│ Name, Email, Username, Roles ├───────────────────────────────────┤
│ │ ½-width separator │
│ │ Client Membership Status │
│ │ Lock icon (OWNER/ADMIN/MANAGER) │
│ │ or Edit button (CLIENT only) │
├───────────────────────────────────┴───────────────────────────────────┤
│ Separator │
│ Profile section (editable if can('EDIT_APPLICANTS') && profile.id) │
│ Name, Email, Phone, Bio │
├─────────────────────────────────────────────────────────────────────────┤
│ Separator │
│ Addresses (grid grid-cols-1 sm:grid-cols-2) │
│ Add address button (trailing in section header) │
└─────────────────────────────────────────────────────────────────────────┘
Client Membership vs Invited State
isInvited = !client && !!clientInvitation- When invited: section label = "Invitation", trailing slot shows kebab menu with "Reinvite client" action + lock icon
- When active client: section label = "Client", trailing slot shows lock icon + "Read only" only
Reinvite Client (frontend)
Uses useResendClientInvitation() from src/features/clients-invitations/hooks/use-clients-invitations.ts.
Only passes id — backend resolves teamId and email from the stored invitation.
await resendInvitation({
id: invitation.id,
email: invitation.email ?? '',
firstName: invitation.firstName ?? '',
lastName: invitation.lastName ?? '',
teamId: '', // backend resolves from stored invitation
})
Button Consistency Rules
- Section-level edit: Edit/Cancel buttons live in
SectionHeadertrailing slot - Cancel button style:
<button className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"> - Save button style:
<Button size="sm" className="h-7 gap-1.5 text-xs"> - AddressEditor: Cancel + Save both at the bottom together (compact form pattern — not at top)
- All edit buttons: icon + text (
<Pencil />+ "Edit")
Backend: Reinvite Permission
ClientController.resendClientInvitation uses:
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserAnyRoleForClientInvitation(#input.id, 'OWNER', 'ADMIN', 'MANAGER')")
hasUserAnyRoleForClientInvitation is defined in AxveroRolePermissionEvaluator — looks up the invitation by ID, resolves teamId, then delegates to hasUserAnyRoleForTeam.
Important: toEntity() in BaseMapper ignores id by design. For resendClientInvitation, the controller manually sets invitation.setId(input.getId()) after mapping.
Documents (detail/documents/index.tsx)
All document logic lives in a single file. Two views are available, toggled via a view switcher persisted in localStorage('ams.docs.view').
View1 — Inline list
- Progress cards (required / optional completion) at the top
- Filter bar: search by name, required/optional toggle, status dropdown
- Collapsible completeness overview table (
CompletenessTable) - Document slot cards (
DocumentSlotCard) grouped by category - Per-slot comment panel (
SlotCommentsPanel) — collapsible, inline below each card - Live comment sync toggle (persisted default: off)
- Add from master + Add custom slot buttons (gated:
can('MANAGE_DOCUMENTS'))
View2 — Master-detail split
Fixed-height container (lg:h-[680px]) with a full-width toolbar above two side-by-side panels. Stacks vertically on mobile.
Toolbar (full width, flex row):
- Left: compact progress bars for required + optional (takes remaining space via
flex-1 min-w-0) - Right (
ml-autogroup): Add from Master, Add Custom Slot (gated), Refresh, Live sync toggle, View toggle
Left panel (55% desktop / full-width mobile):
- Filters sub-header: search input, required/optional toggle, status dropdown
- Slot table (
table-fixedwith<colgroup>fixed widths): status dot, arrow+number+type, name, required, status badge, file count, ext comment count, int comment count - Clicking a slot row → selects it (loads right panel) + auto-expands its file rows
- Clicking the type/number cell → toggles file row expansion without changing selection
- Expanded file rows: clicking anywhere selects the parent slot
- Selected state:
bg-primary/10 border-r-2 border-r-primary/40on both slot row and its expanded file rows (uselast:border-b-0notlast:border-0to preserve right border on last row)
Right panel (45% desktop / min-h-[400px] mobile):
- Shadcn
<Tabs>with two tabs: Details and Comments (with count badge) - Tab preference persisted in
localStorage('ams.docs.panel.tab') - Details tab:
DocumentSlotCardwithhideFilesprop - Comments tab: filter bar (All / External / Internal with counts), paginated comment list,
CommentEditorpinned at bottom - Empty slot state: centered icon + hint text
State:
const [view, setView] = useState<'view1' | 'view2'>() // localStorage persisted
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null)
const [view2ExpandedIds, setView2ExpandedIds] = useState<Set<string>>(new Set())
const [view2PreviewDoc, setView2PreviewDoc] = useState<{...} | null>(null)
const [liveComments, setLiveComments] = useState(false) // default off
Document slot satisfaction: slot.documents.length > 0 means uploaded (PENDING = uploaded, not missing).
FilePreviewDialog (document-slot-card.tsx)
Fetches the file URL with auth token → creates a blob URL → renders based on MIME type.
Supported preview types:
| MIME type | Renderer |
|---|---|
image/* (incl. SVG) | <img> |
application/pdf | <iframe src={blobUrl}> |
video/* | <video controls> |
audio/* | <audio controls> |
text/*, application/json, application/xml | <pre> (via blob.text()) |
text/html | sandboxed <iframe srcDoc> |
| everything else | download fallback |
Pre-signed S3 URLs (contain x-amz-signature) skip the auth fetch and are used directly.
UploadZone (upload-zone.tsx)
Upload rows show three states:
uploading— progress bar + cancel X buttonsuccess— green checkmark (auto-dismissed after 2s)error— redAlertCircleicon + X dismiss button (callsonCancelUpload(fileName))
File size limit: backend is configured for 50MB max (spring.servlet.multipart.max-file-size: 50MB in application.yaml). The UploadZone component also has a maxSizeMb prop (default 50) for client-side pre-filtering.
Decision (detail/decision/index.tsx)
- Summary stat cards: applicant count, required doc progress, total docs
- Current status banner with contextual colour
- Decision panel (gated:
can('DECIDE')): action buttons with optional confirmation - View-only message (gated:
can('VIEW_DECISION'))
Decision actions map:
const DECISION_ACTIONS: Record<string, DecisionAction[]> = {
DRAFT: [{ startReview → IN_REVIEW }],
PENDING: [{ startReview → IN_REVIEW }, { cancel → CANCELLED }],
SUBMITTED: [{ startReview → IN_REVIEW }, { cancel → CANCELLED }],
IN_REVIEW: [{ approve → APPROVED }, { reject → REJECTED }, { requestDocs → WAITING_FOR_DOCUMENTS }, { cancel → CANCELLED }],
WAITING_FOR_DOCUMENTS: [{ resumeReview → IN_REVIEW }, { cancel → CANCELLED }],
APPROVED: [{ reopen → IN_REVIEW }, { archive → ARCHIVED }],
REJECTED: [{ reopen → IN_REVIEW }, { archive → ARCHIVED }],
}
Navigation
Sidebar nav items defined in src/components/layout/data/sidebar-data.tsx:
getApplicationDetailNavItems(orgSlug, applicationId)
// Returns nav items for: Overview, About, Applicants, Documents, Decision
Adding a New Sub-Page
-
Create component:
src/features/applications/detail/[subpage]/index.tsx- Import
useApplicationContextfor application data - Import
useApplicationPermissionsif role-gated - Follow
ContentSectionwrapper pattern for consistent layout
- Import
-
Create route:
src/routes/_authenticated/o/$orgSlug/applications/$applicationId/[subpage]/index.tsxexport const Route = createFileRoute('/_authenticated/o/$orgSlug/applications/$applicationId/[subpage]/')({
component: [SubPage]Component,
}) -
Add to sidebar nav: Update
getApplicationDetailNavItems()insrc/components/layout/data/sidebar-data.tsx -
Add translations: Add keys to
src/lib/i18n/translations/en.ts,ja.ts,de.ts
Key Constraints & Gotchas
- Never re-fetch application in sub-pages — use
useApplicationContext(). Onlydetail/index.tsxfetches. - Status update input — pass
status: { internal: 'STATUS_VALUE' }inupdateApplicationDatainput, not just a string. - Document slot satisfaction — use
slot.documents.length > 0, notslot.status !== 'PENDING'. PENDING = uploaded awaiting review. DocumentSlotCardandUploadZoneare shared with the create wizard — do not duplicate them.- Permissions default to VIEW — if role can't be resolved,
can()returns false for everything except VIEW. - CANCELLED and ARCHIVED are terminal states — no transitions defined, so
DECISION_ACTIONSreturns[]→ "No actions available" message shown.