Skip to content

Last updated:

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

ComponentPurpose
UiButtonButtons with variants (primary, secondary), loading state, icon support
UiInputText input with label, error display, required indicator
UiTextAreaMulti-line text input
UiDropdownSelect dropdown with option configuration
UiSearchInputSearch input with icon
UiSearchSelectSearchable select dropdown
UiAutocompleteAutocomplete input
UiCheckboxCheckbox input
UiTableData table wrapper
UiPaginationPage navigation controls
UiModalModal dialog with header/body/footer slots
UiCardCard container with optional padding
UiAlertAlert message display
UiBadgeStatus badge with color coding
UiStatusTabsTab bar for filtering by status
UiTabsGeneric tab navigation
UiPageHeaderPage title with subtitle and action slots
UiStatCardStatistics display card
UiSearchFiltersFilter bar container
UiFilterButtonFilter toggle button
UiIconButtonIcon-only button
UiDropdownCardDropdown with card content
UiAvatarUser avatar display
UiLabelForm label
UiPinInputPIN/code input
UiSkeletonLoaderLoading placeholder
UiSnackBarToast notification (rendered by provider)
UiConfirmDialogConfirmation modal (rendered by provider)
UiConfirmDialogProviderGlobal confirm dialog mount point
UiDialogProviderGlobal 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
vue
<!-- 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.

vue
<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-model pattern (modelValue prop + update:modelValue emit) 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.

vue
<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, and currentPage as props
  • Events bubble up from TableRow through Table to the page
  • Empty state is handled inline with a v-else row
  • UiPagination is embedded at the bottom

DoctorTableRow -- Row Component

Each row handles its own rendering and action buttons. Permission checks use the $can directive.

vue
<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.

vue
<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 close event -- the dialog provider listens for this
  • Validation uses useFormValidation with useValidationRules helpers
  • API errors from the composable are displayed at the top of the form
  • The submit button shows a loading spinner via the :loading prop
  • 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

vue
<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:

  1. definePageMeta -- Set layout and middleware
  2. Initialize stores and composables -- useDoctorStore(), useDoctor(), useDeleteDoctor()
  3. Initialize shared composables -- useSnackBar(), useConfirmDialog(), useDialog()
  4. Set up useListFilters -- Configure initial filters and the onFilter callback
  5. Template structure:
    • UiPageHeader with title, subtitle slot, and action button
    • [Entity]Filters bound to filters via v-model
    • [Entity]Table receiving store data, emitting edit, delete, page-change
  6. Event handlers:
    • handleAdd[Entity]Click -- Opens create form in dialog
    • handleUpdate[Entity]Click -- Opens update form in dialog with existing data
    • handleDelete[Entity]Click -- Opens confirm dialog, then calls delete composable
  7. onMounted -- Trigger initial data fetch

Auto-Import Behavior

The nuxt.config.ts configures pathPrefix: false:

typescript
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.vue is used as <UiButton />
  • components/configuration/doctor/DoctorTable.vue is 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">:

vue
<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 $emit in templates

Dialog System

Forms are rendered inside dialogs using the useDialog composable. This avoids bloating page templates with modal markup.

typescript
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.

CPR - Clinical Patient Records