File Structure Standards
CPR uses Nuxt 4 with the app/ source directory convention. All application code lives inside app/, while project configuration files (nuxt.config.ts, package.json, tsconfig.json) remain at the project root.
Directory Overview
cpr-frontend/
├── nuxt.config.ts # Nuxt configuration (modules, runtime config, etc.)
├── package.json
├── tsconfig.json
└── app/
├── app.vue # Root Vue component
├── spa-loading-template.html # Loading screen shown while SPA hydrates
├── assets/ # Static assets (CSS, images, fonts)
│ └── css/
│ └── main.css # Tailwind CSS entry point
├── components/ # All Vue components (auto-imported)
│ ├── ui/ # Generic, reusable UI primitives
│ ├── auth/ # Auth-related components
│ ├── configuration/ # Configuration module components
│ ├── patient/ # Patient module components
│ ├── pharmacy/ # Pharmacy module components
│ ├── queue/ # Queue module components
│ ├── report/ # Report components
│ ├── surgery/ # Surgery module components
│ ├── profile/ # Profile components
│ └── utils/ # Utility/helper components
├── composables/ # Vue composables organized by domain
│ ├── useApiFetch.ts # Core API fetch wrapper
│ ├── useApi.ts # Generic CRUD composable
│ ├── useListFilters.ts # Paginated list filter logic
│ ├── useSnackBar.ts # Toast notification composable
│ ├── useConfirmDialog.ts # Confirmation dialog composable
│ ├── useDialog.ts # Dynamic dialog/modal composable
│ ├── useFormValidation.ts # Form validation composable
│ ├── useValidationRules.ts # Reusable validation rule helpers
│ ├── useCanAccess.ts # Permission checking
│ ├── useStatusColor.ts # Status-to-color mapping
│ ├── useReportPrint.ts # Print report helper
│ ├── useQueryFilters.ts # URL query parameter helpers
│ ├── useQueryParam.ts # Single query parameter helper
│ ├── doctor/ # Doctor domain composables
│ │ ├── useDoctor.ts
│ │ ├── useCreateDoctor.ts
│ │ ├── useUpdateDoctor.ts
│ │ └── useDeleteDoctor.ts
│ ├── patient/ # Patient domain composables
│ ├── insurance/ # Insurance domain composables
│ └── ... # Other domain folders
├── constants/ # Static constant values
│ ├── insurance.ts
│ ├── medicine.ts
│ ├── pharmacy.ts
│ └── procedure.ts
├── data/ # Static data files (e.g., address lookups)
│ └── address/
├── layouts/ # Nuxt layout components
│ ├── default.vue # Main app layout (sidebar + header)
│ ├── auth.vue # Login/reset password layout
│ ├── configuration.vue # Configuration module layout
│ ├── pharmacy.vue # Pharmacy module layout
│ ├── queue.vue # Queue module layout
│ └── print.vue # Print-optimized layout
├── middleware/ # Route middleware
│ ├── auth.ts # Authentication guard
│ └── reset-password.ts # Reset password flow guard
├── pages/ # File-based routing
│ ├── index.vue # Root redirect
│ ├── login.vue # Login page
│ ├── profile.vue # User profile page
│ ├── settings.vue # User settings page
│ ├── dashboard/ # Dashboard module
│ ├── patient/ # Patient module
│ ├── queue/ # Queue module
│ ├── pharmacy/ # Pharmacy module
│ ├── configuration/ # Configuration module (doctors, procedures, etc.)
│ ├── surgery-schedule/ # Surgery scheduling module
│ └── reset-password/ # Password reset flow
├── plugins/ # Nuxt plugins
│ ├── permissions.ts # Permission directive ($can)
│ └── unregister-sw.client.ts # Service worker cleanup
├── sevices/ # API service classes (note: folder name is intentional)
│ ├── basicCrud.service.ts # Base CRUD service class
│ ├── doctor.service.ts # Doctor API service
│ ├── auth.sevice.ts # Auth API service
│ ├── insurance.service.ts # Insurance API service
│ └── ... # Other domain services
├── stores/ # Pinia stores
│ ├── auth.store.ts # Auth state (user, token)
│ ├── doctor.store.ts # Doctor list + pagination
│ ├── patient.store.ts # Patient state
│ ├── enums.store.ts # Shared enum data from API
│ ├── settings.ts # App settings
│ └── ... # Other domain stores
├── types/ # TypeScript type definitions
│ ├── apiReponse.type.ts # API response types (ApiResponse, PaginatedResourceResponse)
│ ├── doctor.type.ts # Doctor and DoctorFilters interfaces
│ ├── patient.type.ts # Patient types
│ ├── user.type.ts # User/auth types
│ └── ... # Other domain types
└── utils/ # Utility functions (auto-imported)
├── apiRequesUtils.ts # generateQueryStringFromParams
├── dateFormatter.ts # Date formatting helpers
└── moneyFormatter.ts # Currency formatting helpersFile Naming Conventions
| Directory | Convention | Example |
|---|---|---|
components/ui/ | PascalCase, Ui prefix | UiButton.vue, UiTable.vue |
components/[module]/ | PascalCase, descriptive | DoctorTable.vue, DoctorFilters.vue |
composables/ | camelCase, use prefix | useDoctor.ts, useListFilters.ts |
sevices/ | camelCase, .service.ts suffix | doctor.service.ts, basicCrud.service.ts |
stores/ | camelCase, .store.ts suffix | doctor.store.ts, auth.store.ts |
types/ | camelCase, .type.ts suffix | doctor.type.ts, apiReponse.type.ts |
utils/ | camelCase | dateFormatter.ts, moneyFormatter.ts |
constants/ | camelCase | insurance.ts, medicine.ts |
pages/ | kebab-case directories, index.vue per route | configuration/doctors/index.vue |
layouts/ | kebab-case | default.vue, configuration.vue |
middleware/ | kebab-case | auth.ts, reset-password.ts |
Directory Details
components/
Components are auto-imported with pathPrefix: false (configured in nuxt.config.ts). This means you use component names directly without directory prefixes:
<!-- Use DoctorTable directly, NOT ConfigurationDoctorDoctorTable -->
<DoctorTable :items="doctors" :pagination="pagination" />Components are organized into two tiers:
ui/-- Generic, reusable primitives (UiButton,UiTable,UiModal, etc.)[module]/-- Domain-specific components organized by module
See Component Architecture for full details.
composables/
Composables are organized in two ways:
- Root-level shared composables -- Used across the entire app (
useApiFetch.ts,useSnackBar.ts,useListFilters.ts) - Domain folders -- Composables specific to a feature domain (
doctor/useDoctor.ts,doctor/useCreateDoctor.ts)
Each domain folder typically contains:
use[Entity].ts-- Fetch/list datauseCreate[Entity].ts-- Create operationuseUpdate[Entity].ts-- Update operationuseDelete[Entity].ts-- Delete operation
sevices/
Note
The folder is named sevices/ (missing an 'r'). This is an intentional existing convention -- do not rename it.
Service classes encapsulate API calls. Most services either extend BasicCrudService or follow the same pattern with custom methods. Each service file exports a singleton instance:
export const doctorService = new DoctorService();
export default doctorService;stores/
Pinia stores hold shared reactive state. Each store uses the Composition API style (defineStore with a setup function). Stores typically hold:
- A
listref for collection data - A
paginationref for pagination metadata - Any computed getters or actions needed
types/
TypeScript interfaces and types for each domain. Always define:
- The main entity interface (e.g.,
Doctor) - A filters interface if the entity has list filtering (e.g.,
DoctorFilters) - Any related sub-types (e.g.,
DoctorRole)
utils/
Utility functions are auto-imported by Nuxt. Keep utilities pure (no Vue reactivity, no side effects). Current utilities:
generateQueryStringFromParams-- ConvertsGetApiParams<T>into flat query params for API requestsdateFormatter-- Date display helpersmoneyFormatter-- Currency display helpers
constants/
Static values that do not change at runtime. Use for dropdown options, fixed configuration values, or lookup tables that are not fetched from the API.
data/
Large static data sets (e.g., Philippine address data). Use for datasets too large or too static to fetch from the API.
Adding a New Module: Step-by-Step
This walkthrough uses a hypothetical "Appointment" module as an example.
Step 1: Define types
Create app/types/appointment.type.ts:
export interface Appointment {
id: number;
patient_id: number;
doctor_id: number;
scheduled_at: string;
status: string;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface AppointmentFilters {
search?: string;
status?: string;
doctor_id?: number;
page?: number;
perPage?: number;
}Step 2: Create the service
Create app/sevices/appointment.service.ts:
import type { ApiResponse, paginatedData, PaginatedResourceResponse } from '~/types/apiReponse.type';
import type { FetchError } from 'ofetch';
import * as Sentry from '@sentry/nuxt';
import type { Appointment, AppointmentFilters } from '~/types/appointment.type';
class AppointmentService {
private getErrorMessage(err: unknown): string {
const fetchError = err as FetchError<unknown>;
return (
(fetchError.data as { message?: string })?.message ??
fetchError.message ??
'Something went wrong'
);
}
async getAppointments(filters: AppointmentFilters = {}): Promise<paginatedData<Appointment[]>> {
try {
const params: Record<string, string> = {};
if (filters.search) params.search = filters.search;
if (filters.status) params.status = filters.status;
if (filters.page) params.page = String(filters.page);
if (filters.perPage) params.per_page = String(filters.perPage);
const res = await useApiFetch<PaginatedResourceResponse<Appointment>>('/appointments', {
method: 'GET',
params,
});
return { data: res.data, ...res.meta, links: res.links } as paginatedData<Appointment[]>;
} catch (err: unknown) {
const message = this.getErrorMessage(err);
Sentry.captureException(err);
throw new Error(message);
}
}
async createAppointment(params: Partial<Appointment>): Promise<Appointment> {
try {
const res: ApiResponse<Appointment> = await useApiFetch('/appointments', {
method: 'POST',
body: params,
});
return res.data;
} catch (err: unknown) {
const message = this.getErrorMessage(err);
Sentry.captureException(err);
throw new Error(message);
}
}
// ... updateAppointment, deleteAppointment follow same pattern
}
export const appointmentService = new AppointmentService();
export default appointmentService;Step 3: Create the store
Create app/stores/appointment.store.ts:
import { defineStore } from 'pinia';
import type { APIPagination } from '~/types/apiReponse.type';
import type { Appointment } from '~/types/appointment.type';
export const useAppointmentStore = defineStore('appointment', () => {
const list = ref<Appointment[]>([]);
const pagination = ref<APIPagination>({
per_page: 15,
total: 0,
from: 0,
to: 0,
current_page: 1,
});
return { list, pagination };
});Step 4: Create composables
Create domain composables in app/composables/appointment/:
useAppointment.ts-- Fetch list, populate storeuseCreateAppointment.ts-- Create operationuseUpdateAppointment.ts-- Update operationuseDeleteAppointment.ts-- Delete operation
Step 5: Create components
Create components in app/components/[module]/appointment/:
AppointmentFilters.vue-- Search/filter barAppointmentTable.vue-- Data tableAppointmentTableRow.vue-- Single table row
Create form components in app/components/[module]/forms/AppointmentForms/:
AppointmentForm.vue-- Create formUpdateAppointmentForm.vue-- Update form
Step 6: Create the page
Create app/pages/[module]/appointments/index.vue:
<script setup lang="ts">
definePageMeta({
layout: 'configuration', // or whichever layout fits
middleware: 'auth',
});
</script>The page composes filters, table, and form components together. See Component Architecture for the full page composition pattern.
Key Configuration
The project's nuxt.config.ts sets up the auto-import behavior:
export default defineNuxtConfig({
ssr: false, // SPA mode
components: [
{
path: '~/components',
pathPrefix: false, // No directory prefix in component names
},
],
future: {
compatibilityVersion: 4, // Nuxt 4 with app/ directory
},
runtimeConfig: {
public: {
API_URL: process.env.API_URL || 'http://localhost:8000/api/v1',
},
},
});