-
- {{ props.prep.title }}
+
+
+
+
+ {{ props.prep.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Minimal {{ props.prep.refSearchNav.minLength || 3 }} karakter untuk mencari
+
+
+
+
+
+
+
+
+ {{ props.prep.addNav.label }}
+
+
+
+
+
+
+
+ {{ props.prep.filterNav.label }}
+
+
+
+
+
+
+
+ {{ props.prep.printNav.label }}
+
+
diff --git a/app/components/pub/custom-ui/pagination/pagination.type.ts b/app/components/pub/custom-ui/pagination/pagination.type.ts
new file mode 100644
index 00000000..5c9d97f5
--- /dev/null
+++ b/app/components/pub/custom-ui/pagination/pagination.type.ts
@@ -0,0 +1,13 @@
+export interface PaginationMeta {
+ recordCount: number
+ // page : current pointer for viewing data
+ page: number
+ // pageSize: limit each page request
+ pageSize: number
+ // totalPage: recourdCount / pageSize
+ totalPage: number
+ // hasNext: check if there is next page
+ hasNext: boolean
+ // hasPrev: check if there is previous page
+ hasPrev: boolean
+}
diff --git a/app/components/pub/custom-ui/pagination/pagination.vue b/app/components/pub/custom-ui/pagination/pagination.vue
new file mode 100644
index 00000000..694e3b7b
--- /dev/null
+++ b/app/components/pub/custom-ui/pagination/pagination.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+ Menampilkan {{ startRecord }}
+ hingga {{ endRecord }}
+ dari {{ formattedRecordCount }} data
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/pub/ui/select/Select.vue b/app/components/pub/ui/select/Select.vue
index 3020ed97..b18bba63 100644
--- a/app/components/pub/ui/select/Select.vue
+++ b/app/components/pub/ui/select/Select.vue
@@ -1,15 +1,14 @@
+
+
+
+
+
+
+
+```
+
+## API Reference
+
+### Methods
+
+- `setFromZodError(zodError)` - Set errors dari ZodError
+- `setErrors(errors)` - Set errors manual
+- `setError(field, message)` - Set error untuk field tertentu
+- `clearError(field)` - Hapus error untuk field tertentu
+- `clearErrors()` - Hapus semua errors
+- `hasError(field)` - Cek apakah ada error untuk field
+- `getError(field)` - Ambil error message untuk field
+
+### Computed Properties
+
+- `hasErrors` - Boolean apakah ada error
+- `errorMessages` - Array semua error messages
+- `firstError` - Error pertama (untuk alert general)
+
+## Field Component
+
+Field component akan otomatis menampilkan error jika:
+1. Field memiliki `id` prop yang sesuai dengan field name
+2. Field menerima `errors` prop
+3. Ada error untuk field tersebut di dalam errors object
+
+Error akan ditampilkan dengan class `.field-error-info` yang sudah di-style dengan warna merah.
diff --git a/app/composables/useFormErrors.ts b/app/composables/useFormErrors.ts
new file mode 100644
index 00000000..2907540f
--- /dev/null
+++ b/app/composables/useFormErrors.ts
@@ -0,0 +1,107 @@
+import type { ZodError } from 'zod'
+import type { FormErrors } from '~/types/error'
+
+/**
+ * Composable untuk menangani form validation errors seperti Laravel
+ * Mengkonversi ZodError menjadi format yang mudah digunakan di template
+ */
+export function useFormErrors() {
+ const errors = ref
({})
+
+ /**
+ * Set errors dari ZodError
+ */
+ function setFromZodError(zodError: ZodError) {
+ const newErrors: FormErrors = {}
+
+ zodError.errors.forEach((error) => {
+ const field = error.path.join('.')
+ newErrors[field] = {
+ message: error.message,
+ code: error.code,
+ path: error.path,
+ }
+ })
+
+ errors.value = newErrors
+ }
+
+ /**
+ * Set errors manual (untuk error dari API response)
+ */
+ function setErrors(newErrors: FormErrors) {
+ errors.value = newErrors
+ }
+
+ /**
+ * Set error untuk field tertentu
+ */
+ function setError(field: string, message: string, extra: Record = {}) {
+ errors.value[field] = {
+ message,
+ ...extra,
+ }
+ }
+
+ /**
+ * Hapus error untuk field tertentu
+ */
+ function clearError(field: string) {
+ delete errors.value[field]
+ }
+
+ /**
+ * Hapus semua errors
+ */
+ function clearErrors() {
+ errors.value = {}
+ }
+
+ /**
+ * Cek apakah ada error untuk field tertentu
+ */
+ function hasError(field: string): boolean {
+ return !!errors.value[field]
+ }
+
+ /**
+ * Ambil error message untuk field tertentu
+ */
+ function getError(field: string): string | null {
+ return errors.value[field]?.message || null
+ }
+
+ /**
+ * Cek apakah ada error apapun
+ */
+ const hasErrors = computed(() => Object.keys(errors.value).length > 0)
+
+ /**
+ * Ambil semua error messages sebagai array
+ */
+ const errorMessages = computed(() =>
+ Object.values(errors.value).map(error => error.message),
+ )
+
+ /**
+ * Ambil error pertama (untuk menampilkan alert general)
+ */
+ const firstError = computed(() => {
+ const firstKey = Object.keys(errors.value)[0]
+ return firstKey ? errors.value[firstKey] : null
+ })
+
+ return {
+ errors: readonly(errors),
+ setFromZodError,
+ setErrors,
+ setError,
+ clearError,
+ clearErrors,
+ hasError,
+ getError,
+ hasErrors,
+ errorMessages,
+ firstError,
+ }
+}
diff --git a/app/composables/usePaginatedList.ts b/app/composables/usePaginatedList.ts
new file mode 100644
index 00000000..10f6863a
--- /dev/null
+++ b/app/composables/usePaginatedList.ts
@@ -0,0 +1,164 @@
+import type { DataTableLoader } from '~/components/pub/base/data-table/type'
+import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
+import { refDebounced, useUrlSearchParams } from '@vueuse/core'
+import * as z from 'zod'
+
+// Default query schema yang bisa digunakan semua list
+export const defaultQuerySchema = z.object({
+ q: z.union([z.literal(''), z.string().min(3)]).optional().catch(''),
+ page: z.coerce.number().int().min(1).default(1).catch(1),
+ pageSize: z.coerce.number().int().min(5).max(20).default(10).catch(10),
+})
+
+export const defaultQueryParams = {
+ q: '',
+ page: 1,
+ pageSize: 10,
+}
+
+export type DefaultQueryParams = z.infer
+
+interface UsePaginatedListOptions {
+ // Schema untuk validasi query parameters
+ querySchema?: z.ZodSchema
+ defaultQuery?: Record
+ // Fungsi untuk fetch data
+ fetchFn: (params: any) => Promise<{
+ success: boolean
+ body: {
+ data: T[]
+ meta: {
+ record_totalCount: number
+ }
+ }
+ }>
+ // Nama endpoint untuk logging error
+ entityName: string
+}
+
+export function usePaginatedList(options: UsePaginatedListOptions) {
+ const {
+ querySchema = defaultQuerySchema,
+ defaultQuery = defaultQueryParams,
+ fetchFn,
+ entityName,
+ } = options
+
+ // State management
+ const data = ref([])
+ const isLoading = reactive({
+ isTableLoading: false,
+ })
+
+ // URL state management
+ const queryParams = useUrlSearchParams('history', {
+ initialValue: defaultQuery,
+ removeFalsyValues: true,
+ })
+
+ const params = computed(() => {
+ const result = querySchema.safeParse(queryParams)
+ return result.data || defaultQuery
+ })
+
+ // Pagination state - computed from URL params
+ const paginationMeta = reactive({
+ recordCount: 0,
+ page: params.value.page,
+ pageSize: params.value.pageSize,
+ totalPage: 0,
+ hasNext: false,
+ hasPrev: false,
+ })
+
+ // Search model with debounce
+ const searchInput = ref(params.value.q || '')
+ const debouncedSearch = refDebounced(searchInput, 500) // 500ms debounce
+
+ // Functions
+ async function fetchData() {
+ isLoading.isTableLoading = true
+
+ try {
+ // Use current params from URL state
+ const currentParams = params.value
+
+ const response = await fetchFn(currentParams)
+
+ if (response.success) {
+ const responseBody = response.body
+ data.value = responseBody.data || []
+
+ const pager = responseBody.meta
+ // Update pagination meta from response
+ paginationMeta.recordCount = pager.record_totalCount
+ paginationMeta.page = currentParams.page
+ paginationMeta.pageSize = currentParams.pageSize
+ paginationMeta.totalPage = Math.ceil(pager.record_totalCount / paginationMeta.pageSize)
+ paginationMeta.hasNext = paginationMeta.page < paginationMeta.totalPage
+ paginationMeta.hasPrev = paginationMeta.page > 1
+ }
+ } catch (error) {
+ console.error(`Error fetching ${entityName} list:`, error)
+ data.value = []
+ paginationMeta.recordCount = 0
+ paginationMeta.totalPage = 0
+ paginationMeta.hasNext = false
+ paginationMeta.hasPrev = false
+ } finally {
+ isLoading.isTableLoading = false
+ }
+ }
+
+ // Handle pagination page change
+ function handlePageChange(page: number) {
+ // Update URL params - this will trigger watcher
+ queryParams.page = page
+ }
+
+ // Handle search from header component
+ function handleSearch(searchValue: string) {
+ // Update URL params - this will trigger watcher and refetch data
+ queryParams.q = searchValue
+ queryParams.page = 1 // Reset to first page when searching
+ }
+
+ // Watchers
+ // Watch for URL param changes and trigger refetch
+ watch(params, (newParams) => {
+ // Sync search input with URL params (for back/forward navigation)
+ if (newParams.q !== searchInput.value) {
+ searchInput.value = newParams.q || ''
+ }
+ fetchData()
+ }, { deep: true })
+
+ // Watch debounced search and update URL params (keeping for backward compatibility)
+ watch(debouncedSearch, (newValue) => {
+ // Only search if 3+ characters or empty (to clear search)
+ if (newValue.length === 0 || newValue.length >= 3) {
+ queryParams.q = newValue
+ queryParams.page = 1 // Reset to first page when searching
+ }
+ })
+
+ // Initialize data on mount
+ onMounted(() => {
+ fetchData()
+ })
+
+ return {
+ // State
+ data,
+ isLoading,
+ paginationMeta,
+ searchInput,
+ params,
+ queryParams,
+
+ // Functions
+ fetchData,
+ handlePageChange,
+ handleSearch,
+ }
+}
diff --git a/app/composables/useXfetch.ts b/app/composables/useXfetch.ts
index 3f27c07f..87d69f97 100644
--- a/app/composables/useXfetch.ts
+++ b/app/composables/useXfetch.ts
@@ -1,13 +1,5 @@
import type { Pinia } from 'pinia'
-
-export interface XError {
- code: string
- message: string
- expectedVal?: string
- givenVal?: string
-}
-
-export type XErrors = Record
+import type { XError, XErrors } from '~/types/error'
export interface XfetchResult {
success: boolean
diff --git a/app/pages/(features)/org-src/division/index.vue b/app/pages/(features)/org-src/division/index.vue
new file mode 100644
index 00000000..aa674b28
--- /dev/null
+++ b/app/pages/(features)/org-src/division/index.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/app/pages/(features)/org-src/installation/index.vue b/app/pages/(features)/org-src/installation/index.vue
new file mode 100644
index 00000000..4131d387
--- /dev/null
+++ b/app/pages/(features)/org-src/installation/index.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/app/pages/(features)/org-src/unit/index.vue b/app/pages/(features)/org-src/unit/index.vue
new file mode 100644
index 00000000..801105eb
--- /dev/null
+++ b/app/pages/(features)/org-src/unit/index.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/app/types/error.ts b/app/types/error.ts
new file mode 100644
index 00000000..78b4ed50
--- /dev/null
+++ b/app/types/error.ts
@@ -0,0 +1,27 @@
+/**
+ * Base interface untuk error yang digunakan di seluruh aplikasi
+ */
+export interface XError {
+ /** Pesan error yang akan ditampilkan */
+ message: string
+ /** Kode error (opsional) */
+ code?: string
+ /** Nilai yang diharapkan (untuk validasi) */
+ expectedVal?: string
+ /** Nilai yang diberikan (untuk validasi) */
+ givenVal?: string
+ /** Path field yang error (untuk form validation) */
+ path?: readonly (string | number)[]
+ /** Properties tambahan lainnya */
+ [key: string]: any
+}
+
+/**
+ * Collection of errors dengan key sebagai field name
+ */
+export type XErrors = Record
+
+/**
+ * Form errors type alias untuk kompatibilitas
+ */
+export type FormErrors = XErrors