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.tsx → components/data-table/DataTable.
- features/item-catalog/components/ItemTypeBadge.tsx → components/display/Badge.
- components/common/ConfirmDialog.tsx → components/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 therailCollapsedflag onLayoutContext, whichAppShellreads to switch its grid template between260px 1fr(expanded) and72px 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/ENlocale switch and a disabledAccountplaceholder. 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) |
sm–md (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 whosemin-widththe viewport currently meets. Subscribes towindow.matchMediaso consumers re-render on resize.useIsBelow(token): boolean—truewhen the viewport is below the named breakpoint. The canonical predicate for layout swaps:DataTable↔DataCardListandStepperorientation default both consumeuseIsBelow('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.
FormActionsdoes not yet emitpadding-bottom: env(safe-area-inset-bottom)whensticky. iOS PWA users on<smmay see the footer pinned under the home-indicator. Tracked separately. - Popover collision avoidance on narrow viewports.
Popoverand menu primitives rely on Radix defaults — no explicitcollisionPaddingis 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/, orsrc/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.