hospital-log/frontend/hospital-log/COMPONENTS_GUIDE.md

9.0 KiB

Component Extraction Guide

📦 Created Components

I've extracted your ObatView into 5 reusable components:

1. PageHeader.vue

Header with title, subtitle, and live clock.

<PageHeader title="Obat" subtitle="Manajemen Obat" />

Props:

  • title (string, required): Main heading
  • subtitle (string, optional): Subheading text

Features:

  • Auto-updating clock (every second)
  • Indonesian date/time format
  • Self-contained lifecycle management

2. SearchInput.vue

Reusable search input with icon.

<SearchInput
  v-model="searchQuery"
  placeholder="Search..."
  @search="handleSearch"
/>

Props:

  • modelValue (string, required): v-model binding
  • placeholder (string, optional): Placeholder text

Events:

  • @update:modelValue: Emits on input change
  • @search: Emits on input (for triggering search)

3. SortDropdown.vue

Dropdown for sorting options.

<SortDropdown
  v-model="sortBy"
  :options="{ id: 'ID', name: 'Name' }"
  label="Sort by:"
  @change="handleSortChange"
/>

Props:

  • modelValue (string, required): Current sort value
  • options (Record<string, string>, required): Sort options
  • label (string, optional): Label text

Events:

  • @update:modelValue: Emits on selection
  • @change: Emits selected value

4. DataTable.vue

Generic table with loading, empty states, and action buttons.

<DataTable
  :data="items"
  :columns="[
    { key: 'id', label: '#' },
    { key: 'name', label: 'Name' },
  ]"
  :is-loading="loading"
  empty-message="No data found"
  @details="handleDetails"
  @update="handleUpdate"
  @delete="handleDelete"
/>

Props:

  • data (T[], required): Array of data objects
  • columns (Array, required): Column configuration
  • isLoading (boolean, optional): Loading state
  • emptyMessage (string, optional): Empty state message

Events:

  • @details: Emits clicked item
  • @update: Emits clicked item
  • @delete: Emits clicked item

5. PaginationControls.vue

Full pagination UI with page numbers and size selector.

<PaginationControls
  :page="pagination.page"
  :page-size="pagination.pageSize"
  :total-count="pagination.totalCount"
  :start-index="pagination.startIndex"
  :end-index="pagination.endIndex"
  :can-go-next="pagination.canGoNext"
  :can-go-previous="pagination.canGoPrevious"
  :get-page-numbers="pagination.getPageNumbers"
  @page-change="pagination.goToPage"
  @page-size-change="handlePageSizeChange"
  @next="pagination.nextPage"
  @previous="pagination.previousPage"
/>

Props: All from usePagination composable Events: Pagination actions


🚀 Quick Start: Creating RekamMedisView

Here's how to create a new view using these components:

<script setup lang="ts">
import Sidebar from "@/components/dashboard/Sidebar.vue";
import Footer from "@/components/dashboard/Footer.vue";
import PageHeader from "@/components/dashboard/PageHeader.vue";
import SearchInput from "@/components/dashboard/SearchInput.vue";
import SortDropdown from "@/components/dashboard/SortDropdown.vue";
import DataTable from "@/components/dashboard/DataTable.vue";
import PaginationControls from "@/components/dashboard/PaginationControls.vue";
import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { usePagination } from "@/composables/usePagination";
import { useDebounce } from "@/composables/useDebounce";
import { useApi } from "@/composables/useApi";
import { DEFAULT_PAGE_SIZE, DEBOUNCE_DELAY } from "@/constants/pagination";

interface RekamMedis {
  id: number;
  id_visit: string;
  diagnosis: string;
  // ... other fields
}

const data = ref<RekamMedis[]>([]);
const searchQuery = ref("");
const sortBy = ref("id");

const router = useRouter();
const route = useRoute();
const api = useApi();
const { debounce } = useDebounce();
const pagination = usePagination({
  initialPage: Number(route.query.page) || 1,
  initialPageSize: Number(route.query.pageSize) || DEFAULT_PAGE_SIZE,
});

// Define your table columns
const tableColumns = [
  { key: "id" as keyof RekamMedis, label: "ID" },
  { key: "id_visit" as keyof RekamMedis, label: "ID Visit" },
  { key: "diagnosis" as keyof RekamMedis, label: "Diagnosis" },
];

// Define sort options
const sortOptions = {
  id: "ID",
  id_visit: "ID Visit",
  diagnosis: "Diagnosis",
};

// Fetch data function
const fetchData = async () => {
  try {
    const queryParams = new URLSearchParams({
      take: pagination.pageSize.value.toString(),
      page: pagination.page.value.toString(),
      orderBy: sortBy.value,
      ...(searchQuery.value && { search: searchQuery.value }),
    });

    const result = await api.get(`/rekam-medis?${queryParams}`);
    data.value = result.data;
    pagination.totalCount.value = result.totalCount;
  } catch (error) {
    console.error("Error:", error);
    data.value = [];
  }
};

const debouncedFetchData = debounce(fetchData, DEBOUNCE_DELAY);

const handleSearch = () => {
  pagination.reset();
  debouncedFetchData();
};

const handleSortChange = () => {
  pagination.reset();
  fetchData();
};

const handlePageSizeChange = (size: number) => {
  pagination.setPageSize(size);
  fetchData();
};

const handleDetails = (item: RekamMedis) => {
  router.push({ name: "rekam-medis-details", params: { id: item.id } });
};

const handleUpdate = (item: RekamMedis) => {
  router.push({ name: "rekam-medis-edit", params: { id: item.id } });
};

const handleDelete = async (item: RekamMedis) => {
  if (confirm(`Delete record ${item.id}?`)) {
    try {
      await api.delete(`/rekam-medis/${item.id}`);
      await fetchData();
    } catch (error) {
      alert("Failed to delete");
    }
  }
};

watch([() => pagination.page.value], fetchData);
watch(searchQuery, (newValue, oldValue) => {
  if (oldValue && !newValue) {
    pagination.reset();
    fetchData();
  }
});

onMounted(() => {
  if (route.query.search) searchQuery.value = route.query.search as string;
  if (route.query.sortBy) sortBy.value = route.query.sortBy as string;
  fetchData();
  document.title = "Rekam Medis - Hospital Log";
});
</script>

<template>
  <div class="bg-light w-full text-dark">
    <div class="flex h-full p-2">
      <Sidebar>
        <PageHeader title="Rekam Medis" subtitle="Manajemen Rekam Medis" />

        <div class="bg-white rounded-xl shadow-md">
          <div class="flex items-center px-4 py-4 justify-between gap-4">
            <SortDropdown
              v-model="sortBy"
              :options="sortOptions"
              label="Urut berdasarkan:"
              @change="handleSortChange"
            />

            <SearchInput
              v-model="searchQuery"
              placeholder="Cari rekam medis"
              @search="handleSearch"
            />
          </div>

          <DataTable
            :data="data"
            :columns="tableColumns"
            :is-loading="api.isLoading.value"
            empty-message="Tidak ada rekam medis"
            @details="handleDetails"
            @update="handleUpdate"
            @delete="handleDelete"
          />

          <PaginationControls
            v-if="!api.isLoading.value && data.length > 0"
            :page="pagination.page"
            :page-size="pagination.pageSize"
            :total-count="pagination.totalCount"
            :start-index="pagination.startIndex"
            :end-index="pagination.endIndex"
            :can-go-next="pagination.canGoNext"
            :can-go-previous="pagination.canGoPrevious"
            :get-page-numbers="pagination.getPageNumbers"
            @page-change="pagination.goToPage"
            @page-size-change="handlePageSizeChange"
            @next="pagination.nextPage"
            @previous="pagination.previousPage"
          />
        </div>
      </Sidebar>
    </div>
    <Footer />
  </div>
</template>

📊 Before vs After

Before (484 lines)

  • All logic in one file
  • Repeated code for clock, search, table
  • Hard to maintain
  • Can't reuse in other views

After (230 lines + reusable components)

  • Clean, readable code
  • Reusable components
  • Easy to test
  • Quick to create new views

🎯 Component Usage Summary

For any new CRUD view, you only need:

  1. Define your data interface
  2. Configure table columns
  3. Copy the script logic (it's almost identical)
  4. Use the 5 components in template

That's it! You can create a new view in ~5 minutes instead of copy-pasting 500 lines.


💡 Tips

  1. DataTable is generic - works with any data type
  2. All components are typed - full TypeScript support
  3. Components are independent - use them separately if needed
  4. No over-engineering - simple props, clear events
  5. DaisyUI compatible - uses your existing design system

🔄 Migration Path

Already have other views? Here's the order:

  1. ObatView (done)
  2. 🔲 RekamMedisView (use example above)
  3. 🔲 TindakanView (same pattern)
  4. 🔲 UsersView (same pattern)

Each should take ~10 minutes to refactor.