Component Architecture Standards
CPR uses a three-tier component architecture. Each tier has a distinct responsibility, and components compose together to build complete pages.
The Three Tiers
┌─────────────────────────────────────────────────┐
│ Page Components (app/pages/) │
│ Orchestrate domain + UI components, handle │
│ routing, layout selection, and page-level state │
├─────────────────────────────────────────────────┤
│ Domain Components (app/components/[module]/) │
│ Module-specific: DoctorTable, DoctorFilters, │
│ PatientMedicalRecord, etc. │
├─────────────────────────────────────────────────┤
│ UI Components (app/components/ui/) │
│ Generic primitives: UiButton, UiTable, │
│ UiModal, UiInput, UiPagination, etc. │
└─────────────────────────────────────────────────┘Tier 1: UI Components
Location: app/components/ui/
UI components are generic, reusable building blocks with no domain logic. They are prefixed with Ui to distinguish them from domain components.
Available UI Components
| Component | Purpose |
|---|---|
UiButton | Buttons with variants (primary, secondary), loading state, icon support |
UiInput | Text input with label, error display, required indicator |
UiTextArea | Multi-line text input |
UiDropdown | Select dropdown with option configuration |
UiSearchInput | Search input with icon |
UiSearchSelect | Searchable select dropdown |
UiAutocomplete | Autocomplete input |
UiCheckbox | Checkbox input |
UiTable | Data table wrapper |
UiPagination | Page navigation controls |
UiModal | Modal dialog with header/body/footer slots |
UiCard | Card container with optional padding |
UiAlert | Alert message display |
UiBadge | Status badge with color coding |
UiStatusTabs | Tab bar for filtering by status |
UiTabs | Generic tab navigation |
UiPageHeader | Page title with subtitle and action slots |
UiStatCard | Statistics display card |
UiSearchFilters | Filter bar container |
UiFilterButton | Filter toggle button |
UiIconButton | Icon-only button |
UiDropdownCard | Dropdown with card content |
UiAvatar | User avatar display |
UiLabel | Form label |
UiPinInput | PIN/code input |
UiSkeletonLoader | Loading placeholder |
UiSnackBar | Toast notification (rendered by provider) |
UiConfirmDialog | Confirmation modal (rendered by provider) |
UiConfirmDialogProvider | Global confirm dialog mount point |
UiDialogProvider | Global dynamic dialog mount point |
UI Component Conventions
- Always prefix with
Ui - Accept props for customization, never hard-code domain values
- Use slots for flexible content areas
- Emit typed events, never mutate props directly
<!-- UiPageHeader usage with slots -->
<UiPageHeader title="Doctor Catalog">
<template #subtitle>
<span class="text-gray-500">{{ total }} doctors configured</span>
</template>
<template #actions>
<UiButton variant="primary" :left-icon="PlusIcon" @click="onAdd">
Add Doctor
</UiButton>
</template>
</UiPageHeader>Tier 2: Domain Components
Location: app/components/[module]/
Domain components are module-specific and contain business logic relevant to their feature area. They are organized in subfolders matching the module they belong to.
Standard CRUD Component Set
Every entity that has a list/detail page follows this pattern:
app/components/configuration/
├── doctor/
│ ├── DoctorFilters.vue # Search and filter controls
│ ├── DoctorTable.vue # Data table with columns
│ └── DoctorTableRow.vue # Single row rendering
├── forms/
│ └── DoctorForms/
│ ├── DoctorForm.vue # Create form (used in dialog)
│ └── UpdateDoctorForm.vue # Update form (used in dialog)DoctorFilters -- Filter Bar Component
The filter component manages search input and filter controls. It uses v-model to sync filter values with the parent page.
<template>
<div class="grid gap-4 mb-2 p-2 rounded-2xl shadow-sm border border-gray-200/75 ...">
<div class="lg:col-span-6">
<UiSearchInput
:model-value="searchQuery"
placeholder="Search by name or specialty..."
@update:model-value="$emit('update:searchQuery', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
searchQuery: string;
}>();
defineEmits<{
(e: 'update:searchQuery', value: string): void;
}>();
</script>Key patterns:
- Uses
v-modelpattern (modelValueprop +update:modelValueemit) for two-way binding - Filter components are self-contained UI -- they do not call APIs directly
- The parent page is responsible for reacting to filter changes
DoctorTable -- Data Table Component
The table component receives data via props and delegates row rendering to a TableRow sub-component.
<template>
<UiCard padding="none">
<div class="overflow-x-auto min-h-[40vh] rounded-tl-2xl rounded-tr-2xl">
<table class="w-full">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr class="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-6 py-4 ...">Name</th>
<th class="px-6 py-4 ...">Specialty</th>
<th class="px-6 py-4 ...">Status</th>
<th class="px-6 py-4 ... text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template v-if="items?.length">
<DoctorTableRow
v-for="doctor in items"
:key="doctor.id"
:doctor="doctor"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</template>
<tr v-else>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
No doctors found
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:current-page="currentPage"
:per-page="Number(pagination.per_page) || 15"
:total="Number(pagination.total) || 0"
@update:current-page="$emit('page-change', $event)"
/>
</UiCard>
</template>
<script setup lang="ts">
import type { Doctor } from '~/types/doctor.type';
import type { APIPagination } from '~/types/apiReponse.type';
defineProps<{
items: Doctor[];
pagination: APIPagination;
currentPage: number;
}>();
defineEmits<{
(e: 'edit' | 'delete', doctor: Doctor): void;
(e: 'page-change', page: number): void;
}>();
</script>Key patterns:
- Table receives
items,pagination, andcurrentPageas props - Events bubble up from
TableRowthroughTableto the page - Empty state is handled inline with a
v-elserow UiPaginationis embedded at the bottom
DoctorTableRow -- Row Component
Each row handles its own rendering and action buttons. Permission checks use the $can directive.
<template>
<tr class="hover:bg-gray-200/50 transition-colors text-sm">
<td class="px-6 py-2">
<span class="capitalize">{{ doctor.full_name }}</span>
</td>
<td class="px-6 py-2">
{{ doctor.doctor_role?.name || 'N/A' }}
</td>
<td class="px-6 py-2">
<UiBadge :status="doctor.status" />
</td>
<td class="px-6 py-2 text-right">
<div class="flex items-center justify-end gap-2">
<button
v-if="$can('doctors.update')"
class="text-blue-600 hover:text-blue-700 ..."
@click="$emit('edit', doctor)"
>
<PencilIcon class="w-4 h-4" />
</button>
<button
v-if="$can('doctors.delete')"
class="text-red-600 hover:text-red-700 ..."
@click="$emit('delete', doctor)"
>
<TrashIcon class="w-4 h-4" />
</button>
</div>
</td>
</tr>
</template>
<script setup lang="ts">
import { PencilIcon, TrashIcon } from '@heroicons/vue/24/outline';
import type { Doctor } from '~/types/doctor.type';
defineProps<{
doctor: Doctor;
}>();
defineEmits<{
(e: 'edit' | 'delete', doctor: Doctor): void;
}>();
</script>DoctorForm -- Create Form Component
Form components are rendered inside a dialog via useDialog. They handle their own validation and API calls.
<template>
<form class="max-h-[70vh] overflow-y-auto" @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- API error display -->
<div v-if="error" class="col-span-full p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{{ error }}
</div>
<div>
<UiInput
v-model="form.first_name"
:error="errors.first_name"
label="First Name"
placeholder="e.g., Juan"
required="true"
/>
</div>
<div>
<UiInput
v-model="form.last_name"
:error="errors.last_name"
label="Last Name"
placeholder="e.g., Dela Cruz"
required="true"
/>
</div>
<div>
<UiDropdown
v-model="form.doctor_role_id"
:options="doctorRoles"
:error="errors.doctor_role_id"
label="Specialty"
placeholder="Select Specialty"
required="true"
option-value="id"
option-label="name"
/>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 mt-6">
<UiButton variant="secondary" type="button" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" type="submit" :loading="loading">Add Doctor</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { useCreateDoctor } from '~/composables/doctor/useCreateDoctor';
import { useSnackBar } from '~/composables/useSnackBar';
import { useFormValidation } from '~/composables/useFormValidation';
import { required } from '~/composables/useValidationRules';
import type { Doctor } from '~/types/doctor.type';
const emit = defineEmits<{ close: [] }>();
const { loading, error, success, createDoctor } = useCreateDoctor();
const snackbar = useSnackBar();
const form = reactive<Partial<Doctor>>({
first_name: '',
last_name: '',
doctor_role_id: undefined,
status: 'active',
});
const { errors, validate } = useFormValidation<Partial<Doctor>>({
first_name: [required('First Name')],
last_name: [required('Last Name')],
doctor_role_id: [required('Specialty')],
});
const handleSubmit = async () => {
if (!validate(form)) return;
await createDoctor(form);
if (success.value) {
snackbar.show('Doctor created successfully');
emit('close');
}
};
</script>Key patterns:
- Form emits
closeevent -- the dialog provider listens for this - Validation uses
useFormValidationwithuseValidationRuleshelpers - API errors from the composable are displayed at the top of the form
- The submit button shows a loading spinner via the
:loadingprop - On success, a snackbar notification is shown and the dialog closes
Tier 3: Page Components
Location: app/pages/
Page components orchestrate everything. They wire up composables, stores, domain components, and UI components into a complete view.
Full Doctor Page Example
<template>
<div class="px-8 pb-8">
<UiPageHeader title="Doctor Catalog">
<template #subtitle>
<div class="flex gap-2 mt-2">
<span class="bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-sm font-bold">
{{ doctorStore.pagination.total ?? 0 }}
</span>
<span class="text-gray-500 font-medium">
doctor{{ doctorStore.pagination.total === 1 ? '' : 's' }} configured
</span>
</div>
</template>
<template #actions>
<UiButton
v-if="$can('doctors.create')"
variant="primary"
:left-icon="PlusIcon"
@click="handleAddDoctorClick"
>
Add Doctor
</UiButton>
</template>
</UiPageHeader>
<DoctorFilters v-model:search-query="filters.search" />
<DoctorTable
:items="doctorStore.list"
:pagination="doctorStore.pagination"
:current-page="currentPage"
@edit="handleUpdateDoctorClick"
@delete="handleDeleteDoctorClick"
@page-change="handlePageChange"
/>
</div>
</template>
<script setup lang="ts">
import { PlusIcon } from '@heroicons/vue/24/outline';
import DoctorForm from '~/components/configuration/forms/DoctorForms/DoctorForm.vue';
import UpdateDoctorForm from '~/components/configuration/forms/DoctorForms/UpdateDoctorForm.vue';
import DoctorFilters from '~/components/configuration/doctor/DoctorFilters.vue';
import DoctorTable from '~/components/configuration/doctor/DoctorTable.vue';
import { useDoctor } from '~/composables/doctor/useDoctor';
import { useDeleteDoctor } from '~/composables/doctor/useDeleteDoctor';
import { useDoctorStore } from '~/stores/doctor.store';
import type { Doctor } from '~/types/doctor.type';
import { useListFilters } from '~/composables/useListFilters';
definePageMeta({
layout: 'configuration',
middleware: 'auth',
});
const doctorStore = useDoctorStore();
const doctor = useDoctor();
const deleteDoctorState = useDeleteDoctor();
const snackbar = useSnackBar();
const confirm = useConfirmDialog();
const formDialog = useDialog();
const { filters, currentPage, setPage, filter, refetchCurrentPage } =
useListFilters({
initialFilters: { search: '' },
onFilter: async (params) => {
await doctor.getDoctors(params.search || '', {}, params.page);
},
});
const handlePageChange = async (page: number) => {
await setPage(page);
};
const handleAddDoctorClick = () => {
formDialog.open(DoctorForm, {
title: 'Add New Doctor',
size: 'xl',
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
closeOnBackdrop: false,
});
};
const handleUpdateDoctorClick = (data: Doctor) => {
formDialog.open(UpdateDoctorForm, {
title: `Update ${data.full_name}`,
subtitle: 'Use this form to update the existing doctor data',
size: 'xl',
props: { data },
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
closeOnBackdrop: false,
});
};
const handleDeleteDoctorClick = async (data: Doctor) => {
confirm.open({
title: `Delete ${data.full_name}`,
message: 'Are you sure you want to delete this doctor?',
onConfirm: async () => {
await deleteDoctorState.deleteDoctor(data.id);
if (deleteDoctorState.success.value) {
snackbar.show('Doctor deleted successfully');
await refetchCurrentPage();
} else if (deleteDoctorState.error.value) {
snackbar.show(deleteDoctorState.error.value);
}
},
});
};
onMounted(async () => {
await filter();
});
</script>Page Composition Pattern
Every CRUD list page follows this structure:
definePageMeta-- Set layout and middleware- Initialize stores and composables --
useDoctorStore(),useDoctor(),useDeleteDoctor() - Initialize shared composables --
useSnackBar(),useConfirmDialog(),useDialog() - Set up
useListFilters-- Configure initial filters and theonFiltercallback - Template structure:
UiPageHeaderwith title, subtitle slot, and action button[Entity]Filtersbound tofiltersviav-model[Entity]Tablereceiving store data, emittingedit,delete,page-change
- Event handlers:
handleAdd[Entity]Click-- Opens create form in dialoghandleUpdate[Entity]Click-- Opens update form in dialog with existing datahandleDelete[Entity]Click-- Opens confirm dialog, then calls delete composable
onMounted-- Trigger initial data fetch
Auto-Import Behavior
The nuxt.config.ts configures pathPrefix: false:
components: [
{
path: '~/components',
pathPrefix: false,
},
],This means component names are derived from the filename only, not the directory path. As a result:
components/ui/UiButton.vueis used as<UiButton />components/configuration/doctor/DoctorTable.vueis used as<DoctorTable />
Important: Because path prefixes are disabled, component filenames must be globally unique across the entire components/ directory. If two components have the same filename in different directories, only one will be auto-imported.
Script Setup and TypeScript
All components use <script setup lang="ts">:
<script setup lang="ts">
// Props with TypeScript interface
defineProps<{
doctor: Doctor;
isEditing?: boolean;
}>();
// Typed emits
defineEmits<{
(e: 'edit', doctor: Doctor): void;
(e: 'delete', doctor: Doctor): void;
(e: 'close'): void;
}>();
</script>Props Rules
- Always define props with a TypeScript interface via
defineProps<{ ... }>() - Use imported types from
app/types/for domain objects - Mark optional props with
? - Never use runtime props validation (the
props: {}options API style)
Emits Rules
- Always define emits with
defineEmits<{ ... }>() - Use call-signature syntax for typed payloads:
(e: 'edit', doctor: Doctor): void - Use tuple syntax for no-payload events:
close: [] - Bubble events up through component layers using
$emitin templates
Dialog System
Forms are rendered inside dialogs using the useDialog composable. This avoids bloating page templates with modal markup.
const formDialog = useDialog();
// Open a form component in a dialog
formDialog.open(DoctorForm, {
title: 'Add New Doctor',
size: 'xl', // 'sm' | 'md' | 'lg' | 'xl' | '2xl' ... | 'full'
position: 'center', // 'center' | 'top'
props: { data: doctor }, // Props passed to the form component
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
closeOnBackdrop: false,
});The UiDialogProvider component (mounted in the root layout) renders whichever component is currently active in the dialog state. Form components emit close to dismiss the dialog.
