Skip to content

Architecture

The frontend is a thin SPA that consumes per-module OpenAPI specs from the backend (grinsystem-api) via Orval. It is desktop-first, multi-tenant by JWT (when auth ships), and shares no client-side business logic with the backend.

Module Model (7 BE bounded contexts)

BE module FE folder (created on first ticket) i18n namespace URL stem
identity features/identity/ identity /login, /account
item_catalog features/item-catalog/ item-catalog /catalog
manufacturing features/manufacturing/ manufacturing /manufacturing
warehouse features/warehouse/ warehouse /warehouse
traceability features/traceability/ traceability /traceability
crm features/crm/ crm /crm
invoicing features/invoicing/ invoicing /invoicing

FE filesystem and URL use kebab-case (features/item-catalog/, /catalog). BE's snake_case (item_catalog) only appears in code that references BE artefacts.

Module-owned data — what each BE module owns and what the FE feature folder mirrors — comes directly from BE bounded-contexts spec: grinsystem-api/docs/architecture/bounded-contexts.md. The FE never duplicates that domain split locally; the OpenAPI spec is the contract.

Per-module folder structure (added by the module's first ticket)

When the implement-linear-ticket-fe skill picks up a module's first ticket, it creates this layout. Until then, only the planned shape lives in docs.

features/<module>/
├── pages/                      # Route-level components — thin, delegate to hooks + components
├── components/                 # Feature-specific UI
├── hooks/                      # Custom hooks: mutations, permissions, complex logic
└── stores/                     # Zustand slice for feature UI state (if any)

No features/<module>/api/ folder. Per standards.md § Data Fetching, components use Orval-generated hooks directly (e.g. useGetItem). Cache invalidation helpers live alongside the mutation hook that needs them (in features/<module>/hooks/use<X>Mutations.ts).

No features/<module>/types.ts re-export barrel. Components import generated Zod schemas directly from @/generated/schemas/<module>. FE-only schemas (filter-bar Zod, form schemas) live next to the file that uses them; if reused across the module, they get promoted to features/<module>/schemas.ts.

Directory Structure (current skeleton — only what exists)

src/
├── routes/                     # TanStack Router file-based routes (entry points only, ≤20 lines)
│   ├── __root.tsx              # Root layout, providers
│   ├── _auth.tsx               # Authenticated layout group — auth guard wires up when identity ships
│   └── _auth/
│       └── index.tsx           # / — dashboard placeholder
├── app/
│   ├── providers.tsx           # QueryClientProvider, RouterProvider, i18n bootstrap
│   └── router.ts               # createRouter() with routeTree.gen.ts
├── lib/
│   ├── i18n.ts                 # i18next instance, language detection, namespace loading
│   ├── queryClient.ts          # TanStack Query client + axios + interceptors (auth, idempotency, errors)
│   └── utils.ts                # cn() (clsx + tailwind-merge)
├── stores/
│   └── useUIStore.ts           # Global UI state (sidebar open, toast queue, theme)
├── styles/
│   ├── tokens.css              # @theme block — all CSS variables (single source of truth)
│   ├── components.css          # @layer components — .card, .input-base, .page-container...
│   └── index.css               # Entry: imports tokens + components, initialises Tailwind
├── locales/
│   ├── en/
│   │   ├── common.json         # Nav labels, shared actions, empty states, confirm dialogs
│   │   └── validation.json     # Zod error messages (zod-i18n-map source)
│   └── pl/
│       └── ... (same files, Polish)
├── main.tsx                    # Vite entry
└── routeTree.gen.ts            # Auto-generated by TanStack Router from routes/

Folders that will be added by module work — not pre-created:

src/
├── features/<module>/          # Per-module domain folder (see § Per-module structure above)
├── components/
│   ├── ui/                     # shadcn primitives (Button, Input, Dialog, etc.) — Foundation M2
│   ├── display/                # DateDisplay, QuantityDisplay, DetailField, status badges — Foundation
│   ├── common/                 # PageHeader, SaveButton, FormActions, ConfirmDialog — Foundation
│   ├── forms/                  # RHF field wrappers (TextField, NumberField, SelectField) — Foundation
│   ├── data-table/             # TanStack Table wired to shadcn shell — Foundation
│   └── layout/                 # AppShell, Sidebar, PageContainer — Foundation
├── hooks/                      # Shared utility hooks (useDebounce, usePagination, useTableFilters)
├── locales/{en,pl}/<module>.json   # Per-module namespaces
└── generated/                  # Orval output, regenerated via pnpm orval

Layer Model

Three layers. Upper layers may import lower. Lower layers must never import upper.

features/<module>/pages/
features/<module>/components/    ← know about ERP concepts; use display primitives + common
components/common/
components/display/              ← app-aware, reusable, no feature-specific knowledge
components/ui/                   ← shadcn primitives; pure display; zero domain knowledge

Illegal imports (lint-enforced where possible): - components/ui/Button.tsx importing anything from features/. - components/display/DateDisplay.tsx importing anything from features/. - components/common/PageHeader.tsx importing a specific feature store.

Legal imports: - features/item-catalog/pages/ItemListPage.tsxcomponents/data-table/DataTable. - features/item-catalog/components/ItemTypeBadge.tsxcomponents/display/Badge. - components/common/ConfirmDialog.tsxcomponents/ui/Dialog.

Feature module anatomy (illustrative — item-catalog after its first ticket)

features/item-catalog/
├── pages/
│   ├── ItemListPage.tsx        # uses useListItems() from generated hooks + DataTable
│   ├── ItemDetailPage.tsx      # uses useGetItem(id) + DetailField primitives
│   └── ItemNewPage.tsx         # renders ItemForm
├── components/
│   ├── ItemForm.tsx            # create/edit form; same component for full-page and dialog
│   ├── ItemTypeBadge.tsx       # maps item_type string → Badge variant + i18n label
│   └── ItemTable.tsx           # column defs + DataTable wiring
└── hooks/
    ├── useItemMutations.ts     # wraps generated useCreateItem/useUpdateItem with toast + invalidation
    └── useItemPermissions.ts   # gates UI controls — uses usePermission(Permission.ITEM_CREATE) etc.

# Generated Zod schemas are imported directly from '@/generated/schemas/item-catalog'.
# FE-only form schemas live next to the consuming component, or in features/item-catalog/schemas.ts if reused.

Routing

Conventions: - File path = URL path. $paramName = dynamic segment. _auth prefix = layout route (no URL segment). - Route files are entry points only. They import the Page component from features/<module>/pages/ and wire it up. - Use Route.useParams() and Route.useSearch() — fully typed via TanStack Router inference. - Define search params with validateSearch: zodSchema for typed, validated query strings. - pendingComponent / errorComponent are available for route-transition states, but components handle their own loading and error rendering.

URL slugs are English even though the UI is Polish-first. URL stems map to BE module names (/catalog, /manufacturing, /warehouse, /traceability, /crm, /invoicing) so the FE↔BE correspondence is greppable and the routes don't depend on a translation file. The visible navigation labels are translated normally via t('common:nav.catalog') etc.

Item Catalog routes (illustrative — files do not yet exist):

item_catalog owns one resource — Item — with a item_type discriminator (ingredient | product | intermediate | packaging | consumable | service | resale). The FE routes mirror this with one unified list, not per-type sub-routes. IC-UC-04 ("Search and Browse Catalog") treats all types uniformly with a multi-select type filter; the prototypes' separate "products" / "ingredients" pages are visual presets, not separate resources.

src/routes/_auth/catalog/
├── index.tsx                    → /catalog            redirect to /catalog/items
├── items/
│   ├── index.tsx                → /catalog/items      list, type filter as URL search param
│   ├── $itemId.tsx              → /catalog/items/:id  detail
│   └── new.tsx                  → /catalog/items/new  create
├── groups/
│   └── index.tsx                → /catalog/groups
└── import.tsx                   → /catalog/import     Excel import (IC-UC-06)
// src/routes/_auth/catalog/items/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { ItemListPage } from '../../../../features/item-catalog/pages/ItemListPage'

const itemListSearchSchema = z.object({
  page:      z.number().default(1),
  page_size: z.number().default(20),
  search:    z.string().optional(),
  active:    z.boolean().optional(),
  item_type: z.array(z.enum(['ingredient', 'product', 'intermediate', 'packaging', 'consumable', 'service', 'resale'])).optional(),
  group_id:  z.string().uuid().optional(),
})

export const Route = createFileRoute('/_auth/catalog/items/')({
  validateSearch: itemListSearchSchema,
  component: ItemListPage,
})

User-facing "Products" / "Ingredients" entry points in the sidebar are saved-search URLs (e.g. /catalog/items?item_type=product) — bookmarkable, shareable, no extra routes to maintain.

App shell

Fixed sidebar + content area. Single-column on the right — no separate top header strip; page content sits flush against the top of the viewport so a <PageHeader> is the first thing the user reads.

┌──────────┬──────────────────────────────────────────────────────┐
│ [GS] ⮜   │                                                      │
│ ─Pulpit  │                                                      │
│ ─Katalog │                  <Outlet />                          │
│ ─Produk… │                                                      │
│ ─Magazyn │                                                      │
│ ─CRM     │                                                      │
│ ─────────│                                                      │
│ [Avatar▾]│                                                      │
└──────────┴──────────────────────────────────────────────────────┘

The Sidebar hosts every piece of app chrome that used to live in a top bar:

  • Rail toggle — icon button in the sidebar header next to the logo (PanelLeft / PanelLeftClose). Drives the railCollapsed flag on LayoutContext, which AppShell reads to switch its grid template between 260px 1fr (expanded) and 72px 1fr (rail).
  • User menu — clicking the avatar/user-card at the bottom of the sidebar opens a popover (anchored to the right of the rail). Today it contains the PL / EN locale switch and a disabled Account placeholder. Identity will fill the placeholder with profile + sign-out actions when it ships.

The user-card popover is the single identity surface — there is no duplicate user icon in a top bar.

  • Logo mark and gradient: see design-system.md § Tokens.
  • Active nav link: bg-accent-soft text-accent. Inactive: text-fg-muted. Hover: bg-bg.

Layout tokens that drive spacing: --page-padding-x: 32px.

Responsive contracts

Per-component responsive behaviour. Full policy lives in docs/responsive-strategy.md; this section is the implementation contract for the primitives that compose the app shell + page chrome. Visual tokens (breakpoint values, touch-target minimums, tooltip-on-touch) live in design-system.md § Responsive design.

Primitive <sm (< 640px) smmd (640–767px) ≥ md (≥ 768px) ≥ lg (≥ 1024px)
AppShell Sidebar collapses to drawer; hamburger trigger in the page header; backdrop dismiss on tap. Drawer behaviour (same as <sm). Drawer behaviour. Sidebar mounted inline; rail-state preserved from in-memory UI store.
Sidebar Drawer mode. Auto-closes on route change (no orphan drawer after navigation). Drawer mode. Drawer mode. Inline rail or expanded; rail toggle in sidebar header.
Dialog family Renders as fullscreen Sheet — content scrolls inside, sticky action footer pinned to bottom. Modal centred with a soft backdrop. Modal centred. Modal centred.
PageHeader Title + primary action only. Secondary actions collapse into an overflow ··· menu when action count exceeds a threshold (currently 2). Title + actions; second-row wrap allowed if the action set overflows. Title + actions inline. Title + actions inline.
FilterBar Renders inside a Sheet triggered by a "Filters" button on the page header. Same controls, different container. Inline above the table / card list. Inline. Inline.
DataTable ↔ DataCardList Page renders <DataCardList> (same column / field defs reused). <DataTable> with density="compact". <DataTable>. <DataTable>.
<DesktopOnly> gate Renders the soft-gate UX — a polite card saying "this screen works better on a larger device" with a "continue anyway" link that flips a route-scoped flag. Renders children normally. Renders children normally. Renders children normally.
Stepper Vertical orientation (default driven by useIsBelow('md')). Vertical orientation. Horizontal orientation. Horizontal orientation.
FormActions Stacked column with primary-action-on-top (flex-col-reverse). Sticky footer is opt-in via the sticky prop. Inline row (sm:flex-row) — primary right, secondary left, space-between. Inline row. Inline row.
Sheet primitive Basis for FilterBar-as-Sheet and Dialog-as-fullscreen at <sm. Reusable for ad-hoc slide-in panels. Reusable. Reusable.

The hooks live in src/hooks/useBreakpoint.ts. Two exports:

  • useBreakpoint(): 'sm' | 'md' | 'lg' | 'xl' | '2xl' — returns the largest Tailwind token whose min-width the viewport currently meets. Subscribes to window.matchMedia so consumers re-render on resize.
  • useIsBelow(token): booleantrue when the viewport is below the named breakpoint. The canonical predicate for layout swaps: DataTableDataCardList and Stepper orientation default both consume useIsBelow('md'). Page-chrome primitives that adapt via Tailwind responsive prefixes (e.g. PageHeader, FormActions) do not need it.

Two known gaps in the current contracts, intentionally not documented above so as not to advertise behaviour the primitives don't yet have:

  • Sticky-footer safe-area-inset. FormActions does not yet emit padding-bottom: env(safe-area-inset-bottom) when sticky. iOS PWA users on <sm may see the footer pinned under the home-indicator. Tracked separately.
  • Popover collision avoidance on narrow viewports. Popover and menu primitives rely on Radix defaults — no explicit collisionPadding is set. Acceptable on every screen reviewed during M8, but worth re-checking when feature modules add wider menus.

Both are line items on the component-audit-checklist.md responsive checklist.

/floor/* placeholder

A future, deferred surface for the factory floor kiosk — a wall-mounted tablet running the manufacturing workflows under its own route tree (/floor/*) and its own shell.

  • Different shell: FloorLayout — no sidebar, bottom action bar, big touch targets (≥ 64px), no nested navigation. Designed for gloved fingers, not a mouse.
  • Same data layer: identical Orval hooks, Zod schemas, RHF wiring, idempotency interceptor, error envelope parser. Nothing under src/lib/, src/hooks/, or src/generated/ is duplicated.
  • Different visual language: higher-contrast palette, larger type, no hover affordances. Tokens are shared but the kiosk layer reads the high-contrast subset.
  • Different auth flow: kiosks log in once per shift on a shared device, not per-user. The auth interceptor will route differently for /floor/* requests when identity ships.

Not foundation work — the route tree is reserved but unimplemented. It lights up when the manufacturing module ships its first operator-facing workflow. Cross-link: responsive-strategy.md § What we explicitly defer.