- name
- chdev-frontend
- description
- Frontend architecture conventions for the chdev Vue 3 + Vuetify 3 project. Activate when working on frontend code, creating new views, components, composables, services, or routes.
# chdev Frontend Architecture
Vue 3 (Composition API + `<script setup>`) + Vuetify 3 + TypeScript + Vite.
This skill covers the **frontend** package exclusively; backend is handled by the `chdev-backend` skill.
## Build
**Whenever a file in the `common/` folder is changed**, rebuild the common package from the project root:
```bash
npm run build -w common
```
This must be done before the frontend can pick up the updated schemas and types.
## Tech Stack
- **Runtime**: Node.js, ESM
- **Framework**: Vue 3 (Composition API, `<script setup>`, `<script generic>`)
- **UI**: Vuetify 3 (Vue 3 component library)
- **Validation**: Zod (schemas live in `common/src/schemas/`)
- **Routing**: Vue Router 4
- **Build**: Vite
- **Linting**: Biome
## Project Layout
```
frontend/src/
main.ts # App bootstrap: Vuetify + Router + permission directive
App.vue # Root layout: v-app, v-app-bar, v-main, v-footer
env.d.ts # Vite env type declarations
router/
index.ts # Vue Router setup with auth guard (beforeEach)
services/
api.service.ts # Generic HTTP client: request, get, post, put, delete, getBlob, paginate
auth.service.ts # Auth: login, logout, token management, role checks (singleton class)
<resource>.service.ts # Resource API service (class with static methods)
composables/
useZodForm.ts # Zod-backed reactive form: validate, reset, isDirty, error clearing
useLoading.ts # Loading state wrapper: withLoading(async fn)
useAction.ts # Async action helper: action(fn), loading, error, data refs
useAuth.ts # (legacy — AuthService class is the canonical auth module)
useLoginForm.ts # Login form composable using useAction
usePrestations.ts # (legacy — use services + composables directly in views)
useInvoices.ts # (legacy — use services + composables directly in views)
useContactForm.ts # (legacy — use services + composables directly in views)
useExamples.ts # (legacy — use services + composables directly in views)
components/
common/
AppBar.vue # Top app bar with navigation links + user info + logout
ConfirmDialog.vue # Generic confirmation dialog (yes/no)
GenericForm.vue # Form wrapper: slots form fields, handles validate/cancel/isDirty
PaginatedTable.vue # Paginated data table wrapper around v-data-table-server
<resource>/
<Resource>List.vue # List view: delegates to PaginatedTable, defines headers, custom cell slots
<Resource>Form.vue # Form fields: uses GenericForm slot, maps form/errors to v-text-field etc.
<Resource>Modal.vue # Dialog wrapper: manages dialog state, initial data, cancel/save events
<Resource>Drawer.vue # (optional) Slide-out panel for view/edit details
<Resource>Autocomplete.vue # (optional) Search-based autocomplete component
views/
LoginView.vue # Login page
<Resource>View.vue # Resource CRUD view: list, search, create, edit, delete orchestration
directives/
permission.ts # v-permission directive: hides elements based on user role
```
---
## Core Principle: Separation of Concerns
**Services** handle HTTP communication. **Composables** encapsulate reactive state and business logic. **Views** orchestrate services and composables. **Components** handle UI rendering via slots.
### The Four Layers
| Layer | Responsibility | Pattern |
|-------|---------------|---------|
| **Service** | HTTP requests to backend, typed return values | Static class methods wrapping `ApiService` |
| **Composable** | Reactive state, form validation, async actions | `function useXxx()` returning reactive refs + functions |
| **View** | Page-level orchestration: fetch data, wire actions | `<script setup>` calling services + composables |
| **Component** | UI rendering, slot-based customization | `<script setup>` + `<script generic>` for typed generics |
---
## Service Layer
### API Service (`services/api.service.ts`)
The generic HTTP client. All other services use it.
```ts
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
// Core request function
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T>
// Methods
ApiService.get<T>(endpoint)
ApiService.getBlob(endpoint) // returns Blob (for file downloads)
ApiService.paginate<T>(endpoint, params) // adds PaginationInput query params
ApiService.post<T>(endpoint, body)
ApiService.put<T>(endpoint, body)
ApiService.delete<T>(endpoint)
```
### Resource Service Pattern
Each resource gets its own service class with static methods. **Always** import types from `@chdev/common`.
```ts
import { ApiService } from './api.service';
import type {
Client,
CreateClientInput,
PaginatedResponse,
PaginationInput,
UpdateClientInput,
} from '@chdev/common';
export class ClientService {
static getAll() {
return ApiService.get<Array<Client>>('/clients');
}
static getAllPaginated(params: PaginationInput) {
return ApiService.paginate<PaginatedResponse<Client>>('/clients/paginated', params);
}
static getById(id: number) {
return ApiService.get<Client>(`/clients/${id}`);
}
static create(data: CreateClientInput) {
return ApiService.post<Client>('/clients', data);
}
static update(id: number, data: UpdateClientInput) {
return ApiService.put<Client>(`/clients/${id}`, data);
}
static delete(id: number) {
return ApiService.delete(`/clients/${id}`);
}
}
```
**Rules:**
- Use `ApiService.get<T>()` for full lists (unpaginated, for autocomplete/selects)
- Use `ApiService.paginate<T>()` with `PaginatedResponse<T>` for list views
- Always use `withLoading()` from `useLoading` composable around async calls in views
- Import types via `import type { ... }` from `@chdev/common`
---
## Composable Layer
### useLoading (`composables/useLoading.ts`)
Lightweight loading wrapper. Provides a boolean `isLoading` ref and a `withLoading` helper.
```ts
import { useLoading } from '@/composables/useLoading';
const { isLoading, withLoading } = useLoading();
// Usage
async function fetchData() {
const result = await withLoading(MyService.getAll());
// isLoading is true while the promise resolves
}
```
### useAction (`composables/useAction.ts`)
Async action helper with loading, error, and data refs. Generic over return type with optional mapping.
```ts
import { useAction } from '@/composables/useAction';
const { action, loading, error, data, clearError } = useAction(
(args) => MyService.doSomething(args),
{ map: (res) => res as CustomType } // optional: transform response
);
// In template: {{ action(someArg) }} or {{ loading.value }}
```
### useZodForm (`composables/useZodForm.ts`)
Zod-backed reactive form. Manages form state, validation, errors, and dirty tracking.
```ts
import { useZodForm } from '@/composables/useZodForm';
import { createClientSchema } from '@chdev/common';
const { form, errors, validate, reset, isDirty } = useZodForm(
createClientSchema,
{ companyName: '', email: '', phone: '', address: '', zipCode: 0, city: '', country: '', notes: '' }
);
const result = validate();
if (result.success) {
// result.data is fully typed z.output<typeof schema>
} else {
// result.errors is Record<string, string>
}
```
**Rules:**
- `form` is a Vue `reactive` object — bind directly with `v-model="form.fieldName"`
- `errors` is a flat `Record<string, string>` keyed by field name
- Error auto-resets when the user modifies the field
- `isDirty` computed ref tracks whether form was modified from initial state
- `reset()` reverts to the original values
- For nested schema errors (e.g., `lines.0.prestationId`), the dot-notation key is preserved
### Legacy Composables
Composables like `useAuth`, `useLoginForm`, `useInvoices`, `usePrestations`, `useContactForm`, `useExamples` exist but are **legacy patterns** using manual refs with manual loading/error handling. The modern approach is:
- **Auth**: Use `AuthService` class directly in views
- **Forms**: Use `useZodForm` + `GenericForm` component
- **Async operations**: Use `useLoading` + `useAction` or inline `withLoading` in views
- **Data fetching**: Handle directly in the view with `onMounted` + service calls
**Do not create new composables that manually manage loading/error refs.** Use the building blocks above.
---
## View Layer
### View Pattern
Views orchestrate services, composables, and child components. They define page-level state and handle CRUD operations.
```vue
<template>
<v-container fluid>
<v-row class="align-center">
<v-col><h1 class="text-h4">{{ pageTitle }}</h1></v-col>
<v-col><v-text-field clearable v-model="search" placeholder="Rechercher..." /></v-col>
<v-col class="text-right">
<v-btn color="primary" @click="startCreate">Créer un {{ singularName }}</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<ResourceList
v-model:options="options"
v-model:row-selected="rowSelected"
:is-loading="isLoading"
:items="items"
:items-length="totalItems"
@delete="deleteItem"
@edit="startEdit"
/>
</v-col>
</v-row>
<ResourceModal
v-model="modalOpen"
:item="selectedItem"
:is-creating="isCreating"
:schema="schema"
@cancel="handleCancel"
@saved="handleSaved"
/>
</v-container>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch, computed } from 'vue';
import { useLoading } from '@/composables/useLoading';
import { ResourceService } from '@/services/resource.service';
import { createResourceSchema, updateResourceSchema } from '@chdev/common';
import ResourceList from '@/components/<resource>/<Resource>List.vue';
import ResourceModal from '@/components/<resource>/<Resource>Modal.vue';
import type { Item, CreateItemInput, PaginationInput, UpdateItemInput } from '@chdev/common';
const { isLoading, withLoading } = useLoading();
const items = ref<Array<Item>>([]);
const rowSelected = ref<Item | null>(null);
const selectedItem = ref<Item | null>(null);
const search = ref<string>('');
const totalItems = ref<number>(0);
const modalOpen = ref<boolean>(false);
const isCreating = ref<boolean>(false);
const options = ref<PaginationInput>({
search: '',
totalItems: 0,
page: 1,
itemsPerPage: 10,
sortBy: undefined,
sortDesc: false,
});
watch(options, (newOptions) => fetchItems(newOptions));
watch(search, (newSearch) => { options.value = { ...options.value, search: newSearch || '' }; });
const schema = computed(() => isCreating.value ? createResourceSchema : updateResourceSchema);
async function fetchItems(pagination: PaginationInput) {
const { data, total } = await withLoading(ResourceService.getAllPaginated(pagination));
items.value = data;
totalItems.value = total;
}
async function deleteItem(item: Item) {
await withLoading(ResourceService.delete(item.id));
fetchItems(options.value);
}
function startCreate() {
isCreating.value = true;
selectedItem.value = null;
modalOpen.value = true;
}
function startEdit(item: Item) {
isCreating.value = false;
selectedItem.value = item;
modalOpen.value = true;
}
function handleCancel() {
selectedItem.value = null;
isCreating.value = false;
modalOpen.value = false;
}
async function handleSaved(value: CreateItemInput | UpdateItemInput) {
if ('id' in value) {
await withLoading(ResourceService.update(value.id, value));
} else {
await withLoading(ResourceService.create(value));
}
handleCancel();
fetchItems(options.value);
}
onMounted(() => fetchItems(options.value));
</script>
```
**Rules:**
- Use `v-container fluid` as the outer wrapper
- Search + title + create button in a single `v-row`
- List component in a separate `v-row`
- Modal/Drawer component bound after the list
- Always use `useLoading` + `withLoading` for async operations
- `PaginationInput` type from `@chdev/common` for pagination state
- `watch(options, ...)` triggers fetch on page/sort/items-per-page changes
- `watch(search, ...)` updates pagination options on search input change
- Schema switches between create/update based on `isCreating` computed
- `onMounted` fetches initial data
View on GitHub