Development Process
This guide covers the day-to-day development workflow for the CPR (Computerized Patient Record) frontend, built with Nuxt 4, Vue 3, TypeScript, and Tailwind CSS.
Prerequisites
- Node.js -- version 18.x or later (LTS recommended)
- npm -- ships with Node.js
- A running instance of the CPR backend API (default:
http://localhost:8000/api/v1)
Clone and Setup
git clone https://github.com/RackApp-IT-Solutions/cpr-frontend.git
cd cpr-frontend
npm installnpm install triggers the postinstall script, which runs nuxt prepare to generate TypeScript types and the .nuxt directory.
Environment Variables
Copy the example env file and fill in the values:
cp .env.example .env| Variable | Description | Default |
|---|---|---|
API_URL | Backend API base URL | http://localhost:8000/api/v1 |
SENTRY_DSN | Sentry DSN for error tracking | (empty -- disables Sentry) |
These are exposed via useRuntimeConfig().public in the app. You do not need SENTRY_DSN for local development.
Running the Dev Server
npm run devThis starts the Nuxt development server with hot module replacement. The app runs in SPA mode (ssr: false). Nuxt DevTools are enabled automatically in non-production environments.
Project Structure (Nuxt 4)
Nuxt 4 uses app/ as the source directory. Key directories:
app/
components/ # Auto-imported Vue components (no path prefix)
composables/ # Auto-imported composables (useXxx pattern)
layouts/ # Layout components
middleware/ # Route middleware
pages/ # File-based routing
plugins/ # Nuxt plugins
sevices/ # API service classes
stores/ # Pinia stores
types/ # TypeScript type definitions
constants/ # Constant values and enums
utils/ # Utility functions
assets/css/ # Tailwind CSS entry pointCode Quality Checks
Always run these before committing:
# Check for linting errors (ESLint + Prettier)
npm run lint
# Auto-fix linting errors
npm run lint:fix
# Check formatting
npm run format
# Auto-fix formatting
npm run format:fixThe project uses:
- ESLint with the
@nuxt/eslintconfig and the Prettier plugin to avoid rule conflicts - Prettier with single quotes, semicolons, 2-space tabs, trailing commas (
es5), and 80-character print width
How to Add a New Feature
Follow this layered approach, working from the data layer up to the UI:
1. Define Types
Create app/types/{feature}.type.ts with the resource interface and any form data interface:
// app/types/insurance.type.ts
export interface InsuranceResource {
id: number;
provider_name: string;
plan_type: string;
coverage: number;
status: 'active' | 'inactive';
created_at?: string | null;
updated_at?: string | null;
}
export interface InsuranceFormData {
provider_name: string;
plan_type: string;
coverage: number;
status: 'active' | 'inactive';
}2. Create the Service
Create app/sevices/{feature}.service.ts. Services are classes that call the API via useApiFetch and handle error reporting to Sentry:
// app/sevices/insurance.service.ts
import type { ApiResponse, PaginatedResourceResponse } from '~/types/apiReponse.type';
import type { InsuranceResource, InsuranceFormData } from '~/types/insurance.type';
import * as Sentry from '@sentry/nuxt';
class InsuranceService {
private getErrorMessage(err: unknown): string {
const fetchError = err as FetchError<unknown>;
return (
(fetchError.data as { message?: string })?.message ??
fetchError.message ??
'Something went wrong'
);
}
async getInsurances(search?: string, filters?: { plan_type?: string | number }, page?: number) {
try {
const params: Record<string, string> = {};
if (search) params.search = search;
if (page) params.page = String(page);
return await useApiFetch<PaginatedResourceResponse<InsuranceResource>>('/insurances/', {
method: 'GET',
params,
});
} catch (err: unknown) {
Sentry.captureException(err);
throw new Error(this.getErrorMessage(err));
}
}
async createInsurance(params: InsuranceFormData): Promise<InsuranceResource> {
// ...
}
async updateInsurance(id: number, params: InsuranceFormData): Promise<InsuranceResource> {
// ...
}
async deleteInsurance(id: number): Promise<void> {
// ...
}
}
export const insuranceService = new InsuranceService();Key conventions:
- Use
useApiFetch(fromapp/composables/useApiFetch.ts) for authenticated requests -- it injects the bearer token automatically. - Always wrap API calls in try/catch, call
Sentry.captureException(err), and throw a user-friendly error message. - Export a singleton instance at the bottom of the file.
3. Create Composables
Create a directory app/composables/{feature}/ with one composable per operation:
| File | Purpose |
|---|---|
useInsurance.ts | Fetch list, populate store |
useCreateInsurance.ts | Create operation with loading/error/success state |
useUpdateInsurance.ts | Update operation |
useDeleteInsurance.ts | Delete operation |
Each composable follows the same pattern -- expose loading, error, success refs and an async action function:
export const useCreateInsurance = () => {
const loading = ref(false);
const error = ref('');
const success = ref(false);
const createInsurance = async (data: InsuranceFormData) => {
loading.value = true;
error.value = '';
success.value = false;
try {
await insuranceService.createInsurance(data);
success.value = true;
} catch (err: unknown) {
error.value = (err as { message?: string }).message || 'Failed to create insurance';
} finally {
loading.value = false;
}
};
return { loading, error, success, createInsurance };
};4. Create the Store
Create app/stores/{feature}.store.ts using Pinia's Composition API syntax:
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { InsuranceResource } from '~/types/insurance.type';
import type { APIPagination } from '~/types/api.type';
export const useInsuranceStore = defineStore('insurance', () => {
const list = ref<InsuranceResource[]>([]);
const pagination = ref<APIPagination>({
per_page: 15,
total: 0,
from: 0,
to: 0,
current_page: 1,
});
return { list, pagination };
});5. Build Components
Create components under app/components/{feature}/:
- Table component -- renders the list from the store
- Form components -- create and update forms
- Filter component -- search and filter controls
Components are auto-imported (no manual import statements needed). The pathPrefix: false config means you reference them by filename only, e.g., <InsuranceForm />.
6. Create the Page
Add a page under app/pages/ following Nuxt file-based routing conventions.
How to Add a New CRUD Module
This is a step-by-step checklist for adding a complete CRUD module (using "supplier" as an example):
Types -- Create
app/types/supplier.type.tswithSupplierResourceandSupplierFormDatainterfaces.Service -- Create
app/sevices/supplier.service.tswith methods:getSuppliers(search?, filters?, page?)-- GET with paginationcreateSupplier(data)-- POSTupdateSupplier(id, data)-- PUTdeleteSupplier(id)-- DELETE
Store -- Create
app/stores/supplier.store.tswithlistandpaginationrefs.Composables -- Create
app/composables/supplier/directory with:useSupplier.ts-- fetches list, writes to storeuseCreateSupplier.tsuseUpdateSupplier.tsuseDeleteSupplier.ts
Components -- Create
app/components/configuration/supplier/(or appropriate parent) with:SupplierTable.vue-- data table with paginationSupplierTableRow.vue-- individual row with edit/delete actionsSupplierFilters.vue-- search input + filter dropdownsapp/components/configuration/forms/SupplierForms/SupplierForm.vue-- create formapp/components/configuration/forms/SupplierForms/UpdateSupplierForm.vue-- edit form
Page -- Add
app/pages/configuration/supplier.vue(or a subdirectory if needed).Constants (optional) -- If the module has enum-like values (status options, category lists), add
app/constants/supplier.ts.Verify -- Run
npm run lintandnpm run formatbefore committing.
