Appearance
Frontend Architecture (FSD)
MedRegia follows Feature-Sliced Design — a methodology for structuring large frontend applications around features and domains rather than technical concerns.
Layer Responsibilities
app/ — App Shell (wiring only)
- Router configuration (
createRouter) - Global plugin registration (Pinia, Vue Router)
- Global CSS imports
- No business logic, no API calls
pages/ — Route Shells (~50 lines max)
- One file per route
- Composes widgets and features
- Passes route params down; never contains logic itself
vue
<!-- pages/vendors/VendorsPage.vue — example -->
<script setup lang="ts">
import { VendorListFeature } from '@/features/vendors'
</script>
<template>
<AppLayout>
<VendorListFeature />
</AppLayout>
</template>widgets/ — Layout Blocks
Composite components used across multiple pages:
| Widget | Description |
|---|---|
AppLayout.vue | Main layout wrapper — sidebar + header + content slot |
AppSidebar.vue | Navigation sidebar with route links and permission-gated items |
AppHeader.vue | Top bar — current page title, user menu, quick actions |
QuickActions.vue | Floating shortcut menu for common actions |
features/ — Domain Slices
Each feature is a self-contained slice for one bounded context.
Every feature exposes only through its index.ts barrel.
| Feature | Exports |
|---|---|
auth/ | LoginForm, useAuth, auth store |
vendors/ | VendorList, VendorForm, VendorDetail, useVendors |
invoices/ | InvoiceList, InvoiceForm, InvoiceDetail, useInvoices |
transactions/ | TransactionList, TransactionForm, useTransactions |
dashboard/ | DashboardSummary, useDashboard |
settings/ | SettingsList, useSettings |
reports/ | ReportDownloadButtons |
profile/ | ProfileForm, ChangePasswordForm |
onboarding/ | OnboardingWizard |
shared/ — Cross-Cutting Primitives
The shared/ layer is used by every other layer. It must remain dependency-free (no imports from features or pages).
shared/api/
typescript
// HTTP client factory — never import axios directly outside this folder
export const client: IHttpClient = createHttpClient(...)
// API factories — one per backend module
export const vendorApi = createVendorApi(client)
export const invoiceApi = createInvoiceApi(client)shared/composables/
| Composable | Purpose |
|---|---|
useAuth | Login, logout, token storage, current user |
usePermission | can(collection, action) — reads JWT perm claims |
useApi | Generic async state wrapper (loading, error, data) |
useToast | Show success/error toast notifications |
usePagination | Page number, pageSize, total state |
useFilters | Filter state + debounced URL sync |
useCurrency | Format amounts using the Currency setting |
useConfirmDialog | Reusable confirm-before-delete dialog |
useInvoiceNav | Navigate to invoice detail/edit |
useVendorNav | Navigate to vendor detail/edit |
API Client Pattern
All API calls use the createXxxApi(client) factory pattern:
typescript
// shared/api/vendors.ts
export const createVendorApi = (client: IHttpClient) => ({
getVendors: (params: VendorListParams) =>
client.get<PagedResult<VendorDto>>('/vendors', { params }),
getVendorById: (id: number) =>
client.get<VendorDto>(`/vendors/${id}`),
createVendor: (data: CreateVendorDto) =>
client.post<VendorDto>('/vendors', data),
updateVendor: (id: number, data: UpdateVendorDto) =>
client.put<VendorDto>(`/vendors/${id}`, data),
deleteVendor: (id: number, reason?: string) =>
client.delete(`/vendors/${id}`, { data: { reason } })
})Error Handling
All errors from the API are normalised through normalizeError(err) in shared/api/errors.ts:
typescript
// In any composable
try {
await vendorApi.createVendor(data)
} catch (err) {
const apiError = normalizeError(err)
toast.error(apiError.title ?? 'Something went wrong')
}Raw unknown errors must never propagate past the composable/store layer.