Skip to main content

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 optional trailing slot (used for Edit/Cancel buttons or kebab menus)
  • FieldGridgrid-cols-[96px_1fr] two-column label/value layout. Labels are uppercase text-[10px].
  • FieldRow — single label/value pair inside a FieldGrid. Values use text-sm.
  • AddressBlock — displays a single address with edit (inline button) and delete (kebab menu). Values use text-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 SectionHeader trailing 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-auto group): 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-fixed with <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/40 on both slot row and its expanded file rows (use last:border-b-0 not last:border-0 to 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: DocumentSlotCard with hideFiles prop
  • Comments tab: filter bar (All / External / Internal with counts), paginated comment list, CommentEditor pinned 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 typeRenderer
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/htmlsandboxed <iframe srcDoc>
everything elsedownload 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 button
  • success — green checkmark (auto-dismissed after 2s)
  • error — red AlertCircle icon + X dismiss button (calls onCancelUpload(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 }],
}

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

  1. Create component: src/features/applications/detail/[subpage]/index.tsx

    • Import useApplicationContext for application data
    • Import useApplicationPermissions if role-gated
    • Follow ContentSection wrapper pattern for consistent layout
  2. Create route: src/routes/_authenticated/o/$orgSlug/applications/$applicationId/[subpage]/index.tsx

    export const Route = createFileRoute('/_authenticated/o/$orgSlug/applications/$applicationId/[subpage]/')({
    component: [SubPage]Component,
    })
  3. Add to sidebar nav: Update getApplicationDetailNavItems() in src/components/layout/data/sidebar-data.tsx

  4. Add translations: Add keys to src/lib/i18n/translations/en.ts, ja.ts, de.ts


Key Constraints & Gotchas

  1. Never re-fetch application in sub-pages — use useApplicationContext(). Only detail/index.tsx fetches.
  2. Status update input — pass status: { internal: 'STATUS_VALUE' } in updateApplicationData input, not just a string.
  3. Document slot satisfaction — use slot.documents.length > 0, not slot.status !== 'PENDING'. PENDING = uploaded awaiting review.
  4. DocumentSlotCard and UploadZone are shared with the create wizard — do not duplicate them.
  5. Permissions default to VIEW — if role can't be resolved, can() returns false for everything except VIEW.
  6. CANCELLED and ARCHIVED are terminal states — no transitions defined, so DECISION_ACTIONS returns [] → "No actions available" message shown.