9.0 KiB
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 headingsubtitle(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 bindingplaceholder(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 valueoptions(Record<string, string>, required): Sort optionslabel(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 objectscolumns(Array, required): Column configurationisLoading(boolean, optional): Loading stateemptyMessage(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:
- Define your data interface
- Configure table columns
- Copy the script logic (it's almost identical)
- 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
- DataTable is generic - works with any data type
- All components are typed - full TypeScript support
- Components are independent - use them separately if needed
- No over-engineering - simple props, clear events
- DaisyUI compatible - uses your existing design system
🔄 Migration Path
Already have other views? Here's the order:
- ✅ ObatView (done)
- 🔲 RekamMedisView (use example above)
- 🔲 TindakanView (same pattern)
- 🔲 UsersView (same pattern)
Each should take ~10 minutes to refactor.