fix: solve conflict at material entry
This commit is contained in:
@@ -20,16 +20,20 @@ RSSA - Front End
|
||||
|
||||
- `app.vue`: Main layout
|
||||
- `components` : Contains all reusable UI components.
|
||||
- `components/flow` : Entry point for business logic and workflows. Pages or routes call these flow components to handle API requests and process application logic
|
||||
- `components/app` : View-layer components that manage and present data. These are used within `flow/` to render or handle specific parts of the UI, and return results back to the flow
|
||||
- `components/content` : Entry point for business logic and workflows. Pages or routes call these content components to handle API requests and process application logic
|
||||
- `components/app` : View-layer components that manage and present data. These are used within `content/` to render or handle specific parts of the UI, and return results back to the content
|
||||
- `components/pub` : Public/shared components used across different parts of the app.
|
||||
- `composables` : Contains reusable logic and utility functions (e.g. composables, hooks)..
|
||||
- `layouts` : Reusable UI layout patterns used across pages.
|
||||
- `models` : Contains data definitions or interfaces.
|
||||
- `schemas` : Contains JSON schemas used for validation.
|
||||
- `services` : Contains reusable API calls and business logic.
|
||||
|
||||
|
||||
## Directory Structure for `app/pages`
|
||||
|
||||
- `pages/auth` : Authentication related pages.
|
||||
- `pages/(features)` : Grouped feature modules that reflect specific business flow or domains.
|
||||
- `pages/(features)` : Grouped feature modules that reflect specific business content or domains.
|
||||
|
||||
## Directory Structure for `server/`
|
||||
|
||||
@@ -50,16 +54,16 @@ The basic development workflow follows these steps:
|
||||
- Keep components pure, avoid making HTTP requests directly within them.
|
||||
- They receive data via props and emit events upward.
|
||||
|
||||
### Business Logic in `components/flow`
|
||||
### Business Logic in `components/content`
|
||||
|
||||
- This layer connects the UI with the logic (API calls, validations, navigation).
|
||||
- It composes components from `components/app/`, `components/pub/`, and other flow.
|
||||
- It composes components from `components/app/`, `components/pub/`, and other content.
|
||||
- Also responsible for managing state, side effects, and interactions.
|
||||
|
||||
### Create Pages in `pages/`
|
||||
|
||||
- Define permissions and guards for each page.
|
||||
- Pages load the appropriate flow from `components/flow/`.
|
||||
- Pages load the appropriate content from `components/content/`.
|
||||
- They do not contain UI or logic directly, just route level layout or guards.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
@@ -327,4 +327,14 @@ body {
|
||||
.rounded-sm {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Form Error Styling */
|
||||
.field-error-info {
|
||||
@apply text-xs ml-1;
|
||||
color: hsl(var(--destructive));
|
||||
/* font-size: 0.875rem; */
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* .rounded-md { border-radius: var */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any; errors: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
const items = [
|
||||
{ value: 'item1', label: 'Item 1' },
|
||||
{ value: 'item2', label: 'Item 2' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup :column="1">
|
||||
<Label>Kode</Label>
|
||||
<Field>
|
||||
<Input v-model="data.code" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup v-if="!!props.errors.code">
|
||||
<Label></Label>
|
||||
<span class="text-red-400 text-sm">{{ props.errors.code }}</span>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="1">
|
||||
<Label>Nama</Label>
|
||||
<Field>
|
||||
<Input v-model="data.name" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup v-if="!!props.errors.name">
|
||||
<Label></Label>
|
||||
<span class="text-red-400 text-sm">{{ props.errors.name }}</span>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="1">
|
||||
<Label>Item</Label>
|
||||
<Field>
|
||||
<Select v-model="data.type" :items="items" placeholder="Pilih item" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="1">
|
||||
<Label>Satuan</Label>
|
||||
<Field>
|
||||
<Select v-model="data.uom" :items="items" placeholder="Pilih item" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
export const cols: Col[] = [{ width: 100 }, { width: 250 }, { width: 100 }, { width: 100 }, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Item' }, { label: 'Satuan' }]]
|
||||
|
||||
export const keys = ['code', 'name', 'item_id', 'uom_code', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: Record<string, (row: any, ...args: any[]) => any> = {
|
||||
name: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return `${recX.name}`.trim()
|
||||
},
|
||||
item_id: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return recX.item_id
|
||||
},
|
||||
uom_code: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return recX.uom_code
|
||||
},
|
||||
}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: Record<string, (row: any, ...args: any[]) => any> = {}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import Combobox from '~/components/pub/custom-ui/form/combobox.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
interface DivisionFormData {
|
||||
name: string
|
||||
code: string
|
||||
parentId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
division: {
|
||||
msg: {
|
||||
placeholder: string
|
||||
search: string
|
||||
empty: string
|
||||
}
|
||||
items: {
|
||||
value: string
|
||||
label: string
|
||||
code: string
|
||||
}[]
|
||||
}
|
||||
schema: any
|
||||
initialValues?: Partial<DivisionFormData>
|
||||
errors?: FormErrors
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit': [values: DivisionFormData, resetForm: () => void]
|
||||
'cancel': [resetForm: () => void]
|
||||
}>()
|
||||
|
||||
const formSchema = toTypedSchema(props.schema)
|
||||
|
||||
// Form submission handler
|
||||
function onSubmitForm(values: any, { resetForm }: { resetForm: () => void }) {
|
||||
const formData: DivisionFormData = {
|
||||
name: values.name || '',
|
||||
code: values.code || '',
|
||||
parentId: values.parentId || '',
|
||||
}
|
||||
emit('submit', formData, resetForm)
|
||||
}
|
||||
|
||||
// Form cancel handler
|
||||
function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
emit('cancel', resetForm)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
v-slot="{ handleSubmit, resetForm }" as="" keep-values :validation-schema="formSchema"
|
||||
:initial-values="initialValues"
|
||||
>
|
||||
<form id="entry-form" @submit="handleSubmit($event, (values) => onSubmitForm(values, { resetForm }))">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<FieldGroup>
|
||||
<Label label-for="name">Nama</Label>
|
||||
<Field id="name" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="name" type="text" placeholder="Masukkan nama divisi" autocomplete="off"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label label-for="code">Kode</Label>
|
||||
<Field id="code" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="code">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input id="code" type="text" placeholder="Masukkan kode divisi" autocomplete="off" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup :column="2">
|
||||
<Label label-for="parentId">Kelompok</Label>
|
||||
<Field id="parentId" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="parentId">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
id="parentId" v-bind="componentField" :items="props.division.items"
|
||||
:placeholder="props.division.msg.placeholder" :search-placeholder="props.division.msg.search"
|
||||
:empty-message="props.division.msg.empty"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button type="button" variant="outline" @click="onCancelForm({ resetForm })">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
||||
|
||||
export const cols: Col[] = [
|
||||
{ width: 100 },
|
||||
{ },
|
||||
{ },
|
||||
{ },
|
||||
{ width: 50 },
|
||||
]
|
||||
|
||||
export const header: Th[][] = [
|
||||
[
|
||||
{ label: 'Id' },
|
||||
{ label: 'Nama' },
|
||||
{ label: 'Kode' },
|
||||
{ label: 'Kelompok' },
|
||||
{ label: '' },
|
||||
],
|
||||
]
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'firstName',
|
||||
'cellphone',
|
||||
'birth_place',
|
||||
'action',
|
||||
]
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {
|
||||
name: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
|
||||
},
|
||||
identity_number: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
|
||||
return '(TANPA NIK)'
|
||||
}
|
||||
return recX.identity_number
|
||||
},
|
||||
inPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
outPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
props: {
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
interface Props {
|
||||
data: any[]
|
||||
paginationMeta?: PaginationMeta
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
}>()
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<PubBaseDataTable
|
||||
:rows="data" :cols="cols" :header="header" :keys="keys" :func-parsed="funcParsed"
|
||||
:func-html="funcHtml" :func-component="funcComponent" :skeleton-size="paginationMeta?.pageSize"
|
||||
/>
|
||||
|
||||
<template v-if="paginationMeta">
|
||||
<div v-if="paginationMeta.totalPage > 1">
|
||||
<PubCustomUiPagination :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
import Select from '~/components/pub/custom-ui/form/select.vue'
|
||||
|
||||
interface InstallationFormData {
|
||||
name: string
|
||||
code: string
|
||||
encounterClassCode: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
installation: {
|
||||
msg: {
|
||||
placeholder: string
|
||||
}
|
||||
items: {
|
||||
value: string
|
||||
label: string
|
||||
code: string
|
||||
}[]
|
||||
}
|
||||
schema: any
|
||||
initialValues?: Partial<InstallationFormData>
|
||||
errors?: FormErrors
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit': [values: InstallationFormData, resetForm: () => void]
|
||||
'cancel': [resetForm: () => void]
|
||||
}>()
|
||||
|
||||
const formSchema = toTypedSchema(props.schema)
|
||||
|
||||
// Form submission handler
|
||||
function onSubmitForm(values: any, { resetForm }: { resetForm: () => void }) {
|
||||
const formData: InstallationFormData = {
|
||||
name: values.name || '',
|
||||
code: values.code || '',
|
||||
encounterClassCode: values.encounterClassCode || '',
|
||||
}
|
||||
emit('submit', formData, resetForm)
|
||||
}
|
||||
|
||||
// Form cancel handler
|
||||
function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
emit('cancel', resetForm)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
v-slot="{ handleSubmit, resetForm }" as="" keep-values :validation-schema="formSchema"
|
||||
:initial-values="initialValues"
|
||||
>
|
||||
<form id="entry-form" @submit="handleSubmit($event, (values) => onSubmitForm(values, { resetForm }))">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<FieldGroup>
|
||||
<Label label-for="name">Nama</Label>
|
||||
<Field id="name" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="name" type="text" placeholder="Masukkan nama instalasi" autocomplete="off"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label label-for="code">Kode</Label>
|
||||
<Field id="code" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="code">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input id="code" type="text" placeholder="Masukkan kode instalasi" autocomplete="off" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label label-for="parentId">Encounter Class</Label>
|
||||
<Field id="encounterClassCode" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="encounterClassCode">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
v-bind="componentField"
|
||||
:items="installation.items"
|
||||
:placeholder="installation.msg.placeholder"
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button type="button" variant="outline" @click="onCancelForm({ resetForm })">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
||||
|
||||
export const cols: Col[] = [{ width: 100 }, {}, {}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [
|
||||
[{ label: 'Id' }, { label: 'Nama' }, { label: 'Kode' }, { label: 'Encounter Class' }, { label: '' }],
|
||||
]
|
||||
|
||||
export const keys = ['id', 'name', 'cellphone', 'religion_code', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {
|
||||
name: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return `${recX.firstName} ${recX.lastName || ''}`.trim()
|
||||
},
|
||||
identity_number: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
|
||||
return '(TANPA NIK)'
|
||||
}
|
||||
return recX.identity_number
|
||||
},
|
||||
inPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
outPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
props: {
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
interface Props {
|
||||
data: any[]
|
||||
paginationMeta?: PaginationMeta
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
}>()
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<PubBaseDataTable
|
||||
:rows="data" :cols="cols" :header="header" :keys="keys" :func-parsed="funcParsed"
|
||||
:func-html="funcHtml" :func-component="funcComponent" :skeleton-size="paginationMeta?.pageSize"
|
||||
/>
|
||||
|
||||
<!-- Data info and pagination -->
|
||||
<template v-if="paginationMeta">
|
||||
<div v-if="paginationMeta.totalPage > 1">
|
||||
<PubCustomUiPagination :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const items = [
|
||||
{ value: '1', label: 'item 1' },
|
||||
{ value: '2', label: 'item 2' },
|
||||
{ value: '3', label: 'item 3' },
|
||||
{ value: '4', label: 'item 4' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup>
|
||||
<Label>Items</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Perusahaan Insuransi</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Harga</Label>
|
||||
<Field>
|
||||
<Input v-model="data.price" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
const _doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
export const cols: Col[] = [{}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Aksi' }]]
|
||||
|
||||
export const keys = ['code', 'name', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '~/components/pub/ui/badge'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: any
|
||||
idx?: number
|
||||
}>()
|
||||
|
||||
const doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
|
||||
})
|
||||
|
||||
const badgeVariant = computed(() => {
|
||||
return props.rec.status_code === 1 ? 'default' : 'destructive'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<Badge :variant="badgeVariant">
|
||||
{{ statusText }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const items = [
|
||||
{ value: '1', label: 'item 1' },
|
||||
{ value: '2', label: 'item 2' },
|
||||
{ value: '3', label: 'item 3' },
|
||||
{ value: '4', label: 'item 4' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup>
|
||||
<Label>Nama</Label>
|
||||
<Field>
|
||||
<Input v-model="data.name" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Kode</Label>
|
||||
<Field>
|
||||
<Input v-model="data.code" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Item Group</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>UOM</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Infra</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Harga</Label>
|
||||
<Field>
|
||||
<Input v-model="data.price" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
const _doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
export const cols: Col[] = [{}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Aksi' }]]
|
||||
|
||||
export const keys = ['code', 'name', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '~/components/pub/ui/badge'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: any
|
||||
idx?: number
|
||||
}>()
|
||||
|
||||
const doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
|
||||
})
|
||||
|
||||
const badgeVariant = computed(() => {
|
||||
return props.rec.status_code === 1 ? 'default' : 'destructive'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<Badge :variant="badgeVariant">
|
||||
{{ statusText }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup>
|
||||
<Label>Nama</Label>
|
||||
<Field>
|
||||
<Input v-model="data.name" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Kode</Label>
|
||||
<Field>
|
||||
<Input />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
const _doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
export const cols: Col[] = [{}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Aksi' }]]
|
||||
|
||||
export const keys = ['code', 'name', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '~/components/pub/ui/badge'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: any
|
||||
idx?: number
|
||||
}>()
|
||||
|
||||
const doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
|
||||
})
|
||||
|
||||
const badgeVariant = computed(() => {
|
||||
return props.rec.status_code === 1 ? 'default' : 'destructive'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<Badge :variant="badgeVariant">
|
||||
{{ statusText }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup>
|
||||
<Label>Nama</Label>
|
||||
<Field>
|
||||
<Input v-model="data.name" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Kode</Label>
|
||||
<Field>
|
||||
<Input />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
const _doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
export const cols: Col[] = [{}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [[{ label: 'Kode' }, { label: 'Nama' }, { label: 'Aksi' }]]
|
||||
|
||||
export const keys = ['code', 'name', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import Block from '~/components/pub/custom-ui/form/block.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: any }>()
|
||||
const emit = defineEmits(['update:modelValue', 'event'])
|
||||
|
||||
const data = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const items = [
|
||||
{ value: '1', label: 'item 1' },
|
||||
{ value: '2', label: 'item 2' },
|
||||
{ value: '3', label: 'item 3' },
|
||||
{ value: '4', label: 'item 4' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form id="entry-form">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<Block>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Nama</Label>
|
||||
<Field>
|
||||
<Input type="text" name="identity_number" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Kode</Label>
|
||||
<Field name="sip_number">
|
||||
<Input type="text" name="sip_no" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Metode Pemberian</Label>
|
||||
<Field name="phone">
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Bentuk Sediaan</Label>
|
||||
<Field>
|
||||
<Select :items="items" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Dosis</Label>
|
||||
<Field>
|
||||
<Input type="number" name="outPatient_rate" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Infra</Label>
|
||||
<Field>
|
||||
<Input />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Stock</Label>
|
||||
<Field>
|
||||
<Input type="number" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<FieldGroup :column="2">
|
||||
<Label>Status</Label>
|
||||
<Field>
|
||||
<Input />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Block>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-dud.vue'))
|
||||
|
||||
export const cols: Col[] = [{}, {}, {}, {}, {}, {}, { width: 50 }]
|
||||
|
||||
export const header: Th[][] = [
|
||||
[
|
||||
{ label: 'Kode' },
|
||||
{ label: 'Name' },
|
||||
{ label: 'Kategori' },
|
||||
{ label: 'Golongan' },
|
||||
{ label: 'Metode Pemberian' },
|
||||
{ label: 'Bentuk' },
|
||||
{ label: 'Stok' },
|
||||
{ label: 'Aksi' },
|
||||
],
|
||||
]
|
||||
|
||||
export const keys = ['code', 'name', 'category', 'group', 'method', 'unit', 'total', 'action']
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {
|
||||
cateogry: (rec: unknown): unknown => {
|
||||
return (rec as SmallDetailDto).medicineCategory?.name || '-'
|
||||
},
|
||||
group: (rec: unknown): unknown => {
|
||||
return (rec as SmallDetailDto).medicineGroup?.name || '-'
|
||||
},
|
||||
method: (rec: unknown): unknown => {
|
||||
return (rec as SmallDetailDto).medicineMethod?.name || '-'
|
||||
},
|
||||
unit: (rec: unknown): unknown => {
|
||||
return (rec as SmallDetailDto).medicineUnit?.name || '-'
|
||||
},
|
||||
}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action: (rec: unknown, idx: number): RecComponent => {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
// (_rec) {
|
||||
// return '-'
|
||||
// },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PubBaseDataTable
|
||||
:rows="data"
|
||||
:cols="cols"
|
||||
:header="header"
|
||||
:keys="keys"
|
||||
:func-parsed="funcParsed"
|
||||
:func-html="funcHtml"
|
||||
:func-component="funcComponent"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '~/components/pub/ui/badge'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: any
|
||||
idx?: number
|
||||
}>()
|
||||
|
||||
const doctorStatus = {
|
||||
0: 'Tidak Aktif',
|
||||
1: 'Aktif',
|
||||
}
|
||||
|
||||
const statusText = computed(() => {
|
||||
return doctorStatus[props.rec.status_code as keyof typeof doctorStatus]
|
||||
})
|
||||
|
||||
const badgeVariant = computed(() => {
|
||||
return props.rec.status_code === 1 ? 'default' : 'destructive'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<Badge :variant="badgeVariant">
|
||||
{{ statusText }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrors } from '~/types/error'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import Combobox from '~/components/pub/custom-ui/form/combobox.vue'
|
||||
import FieldGroup from '~/components/pub/custom-ui/form/field-group.vue'
|
||||
import Field from '~/components/pub/custom-ui/form/field.vue'
|
||||
import Label from '~/components/pub/custom-ui/form/label.vue'
|
||||
|
||||
interface UnitFormData {
|
||||
name: string
|
||||
code: string
|
||||
parentId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
unit: {
|
||||
msg: {
|
||||
placeholder: string
|
||||
search: string
|
||||
empty: string
|
||||
}
|
||||
items: {
|
||||
value: string
|
||||
label: string
|
||||
code: string
|
||||
}[]
|
||||
}
|
||||
schema: any
|
||||
initialValues?: Partial<UnitFormData>
|
||||
errors?: FormErrors
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit': [values: UnitFormData, resetForm: () => void]
|
||||
'cancel': [resetForm: () => void]
|
||||
}>()
|
||||
|
||||
const formSchema = toTypedSchema(props.schema)
|
||||
|
||||
// Form submission handler
|
||||
function onSubmitForm(values: any, { resetForm }: { resetForm: () => void }) {
|
||||
const formData: UnitFormData = {
|
||||
name: values.name || '',
|
||||
code: values.code || '',
|
||||
parentId: values.parentId || '',
|
||||
}
|
||||
emit('submit', formData, resetForm)
|
||||
}
|
||||
|
||||
// Form cancel handler
|
||||
function onCancelForm({ resetForm }: { resetForm: () => void }) {
|
||||
emit('cancel', resetForm)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
v-slot="{ handleSubmit, resetForm }" as="" keep-values :validation-schema="formSchema"
|
||||
:initial-values="initialValues"
|
||||
>
|
||||
<form id="entry-form" @submit="handleSubmit($event, (values) => onSubmitForm(values, { resetForm }))">
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<div class="flex flex-col justify-between">
|
||||
<FieldGroup>
|
||||
<Label label-for="name">Nama</Label>
|
||||
<Field id="name" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="name" type="text" placeholder="Masukkan nama unit" autocomplete="off"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label label-for="code">Kode</Label>
|
||||
<Field id="code" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="code">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input id="code" type="text" placeholder="Masukkan kode unit" autocomplete="off" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label label-for="parentId">Instalasi</Label>
|
||||
<Field id="parentId" :errors="errors">
|
||||
<FormField v-slot="{ componentField }" name="parentId">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
id="parentId" v-bind="componentField" :items="unit.items"
|
||||
:placeholder="unit.msg.placeholder" :search-placeholder="unit.msg.search"
|
||||
:empty-message="unit.msg.empty"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button type="button" variant="outline" @click="onCancelForm({ resetForm })">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
Col,
|
||||
KeyLabel,
|
||||
RecComponent,
|
||||
RecStrFuncComponent,
|
||||
RecStrFuncUnknown,
|
||||
Th,
|
||||
} from '~/components/pub/custom-ui/data/types'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type SmallDetailDto = any
|
||||
|
||||
const action = defineAsyncComponent(() => import('~/components/pub/custom-ui/data/dropdown-action-ud.vue'))
|
||||
|
||||
export const cols: Col[] = [
|
||||
{ width: 100 },
|
||||
{ },
|
||||
{ },
|
||||
{ },
|
||||
{ width: 50 },
|
||||
]
|
||||
|
||||
export const header: Th[][] = [
|
||||
[
|
||||
{ label: 'Id' },
|
||||
{ label: 'Nama' },
|
||||
{ label: 'Kode' },
|
||||
{ label: 'Instalasi' },
|
||||
{ label: '' },
|
||||
],
|
||||
]
|
||||
|
||||
export const keys = [
|
||||
'id',
|
||||
'firstName',
|
||||
'cellphone',
|
||||
'birth_place',
|
||||
'action',
|
||||
]
|
||||
|
||||
export const delKeyNames: KeyLabel[] = [
|
||||
{ key: 'code', label: 'Kode' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
]
|
||||
|
||||
export const funcParsed: RecStrFuncUnknown = {
|
||||
name: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return `${recX.frontTitle} ${recX.name} ${recX.endTitle}`.trim()
|
||||
},
|
||||
identity_number: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
if (recX.identity_number?.substring(0, 5) === 'BLANK') {
|
||||
return '(TANPA NIK)'
|
||||
}
|
||||
return recX.identity_number
|
||||
},
|
||||
inPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.inPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
outPatient_itemPrice: (rec: unknown): unknown => {
|
||||
const recX = rec as SmallDetailDto
|
||||
return Number(recX.outPatient_itemPrice.price).toLocaleString('id-ID')
|
||||
},
|
||||
}
|
||||
|
||||
export const funcComponent: RecStrFuncComponent = {
|
||||
action(rec, idx) {
|
||||
const res: RecComponent = {
|
||||
idx,
|
||||
rec: rec as object,
|
||||
component: action,
|
||||
props: {
|
||||
size: 'sm',
|
||||
},
|
||||
}
|
||||
return res
|
||||
},
|
||||
}
|
||||
|
||||
export const funcHtml: RecStrFuncUnknown = {
|
||||
patient_address(_rec) {
|
||||
return '-'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationMeta } from '~/components/pub/custom-ui/pagination/pagination.type'
|
||||
import { cols, funcComponent, funcHtml, funcParsed, header, keys } from './list-cfg'
|
||||
|
||||
interface Props {
|
||||
data: any[]
|
||||
paginationMeta?: PaginationMeta
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
}>()
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<PubBaseDataTable
|
||||
:rows="data" :cols="cols" :header="header" :keys="keys" :func-parsed="funcParsed"
|
||||
:func-html="funcHtml" :func-component="funcComponent" :skeleton-size="paginationMeta?.pageSize"
|
||||
/>
|
||||
|
||||
<template v-if="paginationMeta">
|
||||
<div v-if="paginationMeta.totalPage > 1">
|
||||
<PubCustomUiPagination :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Modal from '~/components/pub/base/modal/modal.vue'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
|
||||
const data = ref([])
|
||||
const entry = ref<any>({})
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Golongan Obat',
|
||||
icon: 'i-lucide-users',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => (isOpen.value = true),
|
||||
},
|
||||
}
|
||||
|
||||
async function getPatientList() {
|
||||
isLoading.isTableLoading = true
|
||||
const resp = await xfetch('/api/v1/medicine-group')
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
}
|
||||
isLoading.isTableLoading = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientList()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<AppMedicineGroupList :data="data" />
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="isOpen" title="Tambah Golongan Obat" size="lg" prevent-outside>
|
||||
<AppMedicineGroupEntryForm v-model="entry" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Modal from '~/components/pub/base/modal/modal.vue'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
|
||||
const data = ref([])
|
||||
const entry = ref<any>({})
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Item',
|
||||
icon: 'i-lucide-users',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => (isOpen.value = true),
|
||||
},
|
||||
}
|
||||
|
||||
async function getPatientList() {
|
||||
isLoading.isTableLoading = true
|
||||
const resp = await xfetch('/api/v1/medicine-group')
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
}
|
||||
isLoading.isTableLoading = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientList()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<AppItemList :data="data" />
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="isOpen" title="Tambah Golongan Obat" size="xl" prevent-outside>
|
||||
<AppItemEntryForm v-model="entry" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Modal from '~/components/pub/base/modal/modal.vue'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
|
||||
const data = ref([])
|
||||
const entry = ref<any>({})
|
||||
const page = ref(1)
|
||||
const rowsPerPage = ref(10)
|
||||
const totalPages = 20
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Golongan Obat',
|
||||
icon: 'i-lucide-users',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => (isOpen.value = true),
|
||||
},
|
||||
}
|
||||
|
||||
async function getPatientList() {
|
||||
isLoading.isTableLoading = true
|
||||
const resp = await xfetch('/api/v1/medicine-group')
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
}
|
||||
isLoading.isTableLoading = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientList()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<AppMedicineGroupList :data="data" />
|
||||
<Pagination v-model:page="page" v-model:rows-per-page="rowsPerPage" :total-pages="totalPages" />
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="isOpen" title="Tambah Golongan Obat" size="lg" prevent-outside>
|
||||
<AppMedicineGroupEntryForm v-model="entry" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Modal from '~/components/pub/base/modal/modal.vue'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
import { getMedicineMethods } from '~/services/medicine-method.service'
|
||||
|
||||
const data = ref([])
|
||||
const entry = ref<any>({})
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Metode Pemberian',
|
||||
icon: 'i-lucide-medicine-bottle',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => (isOpen.value = true),
|
||||
},
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
try {
|
||||
isLoading.isTableLoading = true
|
||||
data.value = await getMedicineMethods()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.isTableLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getData()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<AppMedicineMethodList :data="data" />
|
||||
</div>
|
||||
|
||||
<Modal v-model:open="isOpen" title="Tambah Metode Pemberian" size="lg" prevent-outside>
|
||||
<AppMedicineMethodEntryForm v-model="entry" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const data = ref({
|
||||
name: '',
|
||||
password: '',
|
||||
status: '',
|
||||
type: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<Icon name="i-lucide-user" class="me-2" />
|
||||
<span class="font-semibold">Tambah</span> Obat
|
||||
</div>
|
||||
<AppMedicineEntryForm v-model="data" />
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
|
||||
const data = ref([])
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
summary: false,
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const hreaderPrep: HeaderPrep = {
|
||||
title: 'Obat',
|
||||
icon: 'i-lucide-medicine-bottle',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => navigateTo('/tools-equipment-src/medicine/add'),
|
||||
},
|
||||
}
|
||||
|
||||
async function getPatientList() {
|
||||
isLoading.isTableLoading = true
|
||||
const resp = await xfetch('/api/v1/medicine')
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
}
|
||||
isLoading.isTableLoading = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPatientList()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header :prep="{ ...hreaderPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="my-4 flex flex-1 flex-col gap-4 md:gap-8">
|
||||
<AppMedicineList :data="data" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import Action from '~/components/pub/custom-ui/nav-footer/ba-dr-su.vue'
|
||||
import { z, ZodError } from 'zod'
|
||||
|
||||
const errors = ref({})
|
||||
const data = ref({
|
||||
code: '',
|
||||
name: '',
|
||||
type: '',
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().min(1, 'Code must be at least 1 characters long'),
|
||||
name: z.string().min(1, 'Name must be at least 1 characters long'),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
function onClick(type: string) {
|
||||
if (type === 'cancel') {
|
||||
navigateTo('/tools-equipment-src/device')
|
||||
} else if (type === 'draft') {
|
||||
// do something
|
||||
} else if (type === 'submit') {
|
||||
// do something
|
||||
const input = data.value
|
||||
console.log(input)
|
||||
const errorsParsed: any = {}
|
||||
try {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) {
|
||||
// You can handle the error here, e.g. show a message
|
||||
const errorsCaptures = result?.error?.errors || []
|
||||
const errorMessage = result.error.errors[0]?.message ?? 'Validation error occurred'
|
||||
errorsCaptures.forEach((value: any) => {
|
||||
const keyName = value?.path?.length > 0 ? value.path[0] : 'key'
|
||||
errorsParsed[keyName as string] = value.message || ''
|
||||
})
|
||||
console.log(errorMessage)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
const jsonError = e.flatten()
|
||||
console.log(JSON.stringify(jsonError, null, 2))
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
errors.value = errorsParsed
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-5 border-b border-b-slate-300 pb-3 text-lg xl:text-xl">
|
||||
<Icon name="i-lucide-paint-bucket" class="me-2" />
|
||||
<span class="font-semibold">Tambah</span> Alat Kesehatan
|
||||
</div>
|
||||
<AppDeviceEntryForm v-model="data" :errors="errors" />
|
||||
<div class="my-2 flex justify-end py-2">
|
||||
<Action @click="onClick" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from '~/components/pub/base/data-table/type'
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/prep.vue'
|
||||
|
||||
const data = ref([])
|
||||
|
||||
const refSearchNav: RefSearchNav = {
|
||||
onClick: () => {
|
||||
// open filter modal
|
||||
},
|
||||
onInput: (_val: string) => {
|
||||
// filter patient list
|
||||
},
|
||||
onClear: () => {
|
||||
// clear url param
|
||||
},
|
||||
}
|
||||
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
isTableLoading: false,
|
||||
})
|
||||
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Alat Kesehatan',
|
||||
icon: 'i-lucide-paint-bucket',
|
||||
addNav: {
|
||||
label: 'Tambah',
|
||||
onClick: () => navigateTo('/tools-equipment-src/device/add'),
|
||||
},
|
||||
}
|
||||
|
||||
async function getMaterialList() {
|
||||
isLoading.dataListLoading = true
|
||||
|
||||
const resp = await xfetch('/api/v1/device')
|
||||
if (resp.success) {
|
||||
data.value = (resp.body as Record<string, any>).data
|
||||
}
|
||||
|
||||
isLoading.dataListLoading = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getMaterialList()
|
||||
})
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border p-4">
|
||||
<Header :prep="{ ...headerPrep }" :ref-search-nav="refSearchNav" />
|
||||
<div class="rounded-md border p-4">
|
||||
<AppMaterialList v-if="!isLoading.dataListLoading" :data="data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const division = {
|
||||
msg: {
|
||||
placeholder: '---pilih divisi utama',
|
||||
search: 'kode, nama divisi',
|
||||
empty: 'divisi tidak ditemukan',
|
||||
},
|
||||
items: [
|
||||
{ value: '1', label: 'Medical', code: 'MED' },
|
||||
{ value: '2', label: 'Nursing', code: 'NUR' },
|
||||
{ value: '3', label: 'Admin', code: 'ADM' },
|
||||
{ value: '4', label: 'Support', code: 'SUP' },
|
||||
{ value: '5', label: 'Education', code: 'EDU' },
|
||||
{ value: '6', label: 'Pharmacy', code: 'PHA' },
|
||||
{ value: '7', label: 'Radiology', code: 'RAD' },
|
||||
{ value: '8', label: 'Laboratory', code: 'LAB' },
|
||||
{ value: '9', label: 'Finance', code: 'FIN' },
|
||||
{ value: '10', label: 'Human Resources', code: 'HR' },
|
||||
{ value: '11', label: 'IT Services', code: 'ITS' },
|
||||
{ value: '12', label: 'Maintenance', code: 'MNT' },
|
||||
{ value: '13', label: 'Catering', code: 'CAT' },
|
||||
{ value: '14', label: 'Security', code: 'SEC' },
|
||||
{ value: '15', label: 'Emergency', code: 'EMR' },
|
||||
{ value: '16', label: 'Surgery', code: 'SUR' },
|
||||
{ value: '17', label: 'Outpatient', code: 'OUT' },
|
||||
{ value: '18', label: 'Inpatient', code: 'INP' },
|
||||
{ value: '19', label: 'Rehabilitation', code: 'REB' },
|
||||
{ value: '20', label: 'Research', code: 'RSH' },
|
||||
],
|
||||
}
|
||||
|
||||
export const schema = z.object({
|
||||
name: z.string({
|
||||
required_error: 'Nama wajib diisi',
|
||||
}).min(1, 'Nama divisi wajib diisi'),
|
||||
|
||||
code: z.string({
|
||||
required_error: 'Kode wajib diisi',
|
||||
}).min(1, 'Kode divisi wajib diisi'),
|
||||
|
||||
parentId: z.preprocess(
|
||||
(input: unknown) => {
|
||||
if (typeof input === 'string') {
|
||||
// Handle empty string case
|
||||
if (input.trim() === '') {
|
||||
return undefined
|
||||
}
|
||||
return Number(input)
|
||||
}
|
||||
|
||||
return input
|
||||
},
|
||||
z.number({
|
||||
required_error: 'Kelompok wajib dipilih',
|
||||
}).min(1, 'Kelompok wajib dipilih'),
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import AppDivisonEntryForm from '~/components/app/divison/entry-form.vue'
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||
import { usePaginatedList } from '~/composables/usePaginatedList'
|
||||
import { division as divisionConf, schema as schemaConf } from './entry'
|
||||
|
||||
// #region State & Computed
|
||||
// Dialog state
|
||||
const isFormEntryDialogOpen = ref(false)
|
||||
const isRecordConfirmationOpen = ref(false)
|
||||
|
||||
// Table action rowId provider
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
// Fungsi untuk fetch data division
|
||||
async function fetchDivisionData(params: any) {
|
||||
// Prepare query parameters for pagination and search
|
||||
const urlParams = new URLSearchParams({
|
||||
'page-number': params.page.toString(),
|
||||
'page-size': params.pageSize.toString(),
|
||||
})
|
||||
|
||||
if (params.q) {
|
||||
urlParams.append('search', params.q)
|
||||
}
|
||||
|
||||
return await xfetch(`/api/v1/patient?${urlParams.toString()}`)
|
||||
}
|
||||
|
||||
// Menggunakan composable untuk pagination
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationMeta,
|
||||
searchInput,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
fetchData: getDivisionList,
|
||||
} = usePaginatedList({
|
||||
fetchFn: fetchDivisionData,
|
||||
entityName: 'division',
|
||||
})
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Divisi',
|
||||
icon: 'i-lucide-box',
|
||||
refSearchNav: {
|
||||
placeholder: 'Cari (min. 3 karakter)...',
|
||||
minLength: 3,
|
||||
debounceMs: 500,
|
||||
showValidationFeedback: true,
|
||||
onInput: (_val: string) => {
|
||||
// Handle search input - this will be triggered by the header component
|
||||
},
|
||||
onClick: () => {
|
||||
// Handle search button click if needed
|
||||
},
|
||||
onClear: () => {
|
||||
// Handle search clear
|
||||
},
|
||||
},
|
||||
addNav: {
|
||||
label: 'Tambah Divisi',
|
||||
icon: 'i-lucide-send',
|
||||
onClick: () => {
|
||||
isFormEntryDialogOpen.value = true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
// #endregion
|
||||
|
||||
// #region Functions
|
||||
|
||||
async function handleDeleteRow(record: any) {
|
||||
try {
|
||||
// TODO : hit backend request untuk delete
|
||||
console.log('Deleting record:', record)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch(`/api/v1/division/${record.id}`, {
|
||||
// method: 'DELETE'
|
||||
// })
|
||||
|
||||
// Refresh data setelah berhasil delete
|
||||
await getDivisionList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Record deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Error deleting record:', error)
|
||||
// TODO: Show error message
|
||||
} finally {
|
||||
// Reset record state
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion region
|
||||
|
||||
// #region Form event handlers
|
||||
|
||||
function onCancelForm(resetForm: () => void) {
|
||||
isFormEntryDialogOpen.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function onSubmitForm(values: any, resetForm: () => void) {
|
||||
let isSuccess = false
|
||||
try {
|
||||
// TODO: Implement form submission logic
|
||||
console.log('Form submitted:', values)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch('/api/v1/division', {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify(values)
|
||||
// })
|
||||
|
||||
// If successful, mark as success and close dialog
|
||||
isFormEntryDialogOpen.value = false
|
||||
isSuccess = true
|
||||
|
||||
// Refresh data after successful submission
|
||||
await getDivisionList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Division created successfully')
|
||||
} catch (error: unknown) {
|
||||
console.warn('Error submitting form:', error)
|
||||
isSuccess = false
|
||||
// Don't close dialog or reset form on error
|
||||
// TODO: Show error message to user
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Watchers
|
||||
|
||||
// Watch for row actions
|
||||
watch(recId, () => {
|
||||
switch (recAction.value) {
|
||||
case ActionEvents.showEdit:
|
||||
// TODO: Handle edit action
|
||||
// isFormEntryDialogOpen.value = true
|
||||
break
|
||||
case ActionEvents.showConfirmDelete:
|
||||
// Trigger confirmation modal open
|
||||
isRecordConfirmationOpen.value = true
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle confirmation result
|
||||
function handleConfirmDelete(record: any, action: string) {
|
||||
console.log('Confirmed action:', action, 'for record:', record)
|
||||
handleDeleteRow(record)
|
||||
}
|
||||
|
||||
function handleCancelConfirmation() {
|
||||
// Reset record state when cancelled
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border p-4">
|
||||
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
|
||||
<AppDivisonList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
|
||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Divisi" size="lg" prevent-outside>
|
||||
<AppDivisonEntryForm
|
||||
:division="divisionConf" :schema="schemaConf"
|
||||
:initial-values="{ name: '', code: '', parentId: '' }" @submit="onSubmitForm" @cancel="onCancelForm"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Record Confirmation Modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
<p v-if="record?.firstName"><strong>Nama:</strong> {{ record.firstName }}</p>
|
||||
<p v-if="record?.code"><strong>Kode:</strong> {{ record.cellphone }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</RecordConfirmation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* component style */
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const installationConf = {
|
||||
msg: {
|
||||
placeholder: '---pilih encounter class (fhir7)',
|
||||
},
|
||||
items: [
|
||||
{ value: '1', label: 'Ambulatory', code: 'AMB' },
|
||||
{ value: '2', label: 'Inpatient', code: 'IMP' },
|
||||
{ value: '3', label: 'Emergency', code: 'EMER' },
|
||||
{ value: '4', label: 'Observation', code: 'OBSENC' },
|
||||
{ value: '5', label: 'Pre-admission', code: 'PRENC' },
|
||||
{ value: '6', label: 'Short Stay', code: 'SS' },
|
||||
{ value: '7', label: 'Virtual', code: 'VR' },
|
||||
{ value: '8', label: 'Home Health', code: 'HH' },
|
||||
],
|
||||
}
|
||||
|
||||
export const schemaConf = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Nama instalasi harus diisi',
|
||||
})
|
||||
.min(3, 'Nama instalasi minimal 3 karakter'),
|
||||
|
||||
code: z
|
||||
.string({
|
||||
required_error: 'Kode instalasi harus diisi',
|
||||
})
|
||||
.min(3, 'Kode instalasi minimal 3 karakter'),
|
||||
|
||||
encounterClassCode: z
|
||||
.string({
|
||||
required_error: 'Kelompok encounter class harus dipilih',
|
||||
})
|
||||
.min(1, 'Kelompok encounter class harus dipilih'),
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import AppInstallationEntryForm from '~/components/app/installation/entry-form.vue'
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||
import { usePaginatedList } from '~/composables/usePaginatedList'
|
||||
import { installationConf, schemaConf } from './entry'
|
||||
|
||||
// #region State & Computed
|
||||
// Dialog state
|
||||
const isFormEntryDialogOpen = ref(false)
|
||||
const isRecordConfirmationOpen = ref(false)
|
||||
|
||||
// Table action rowId provider
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
// Fungsi untuk fetch data installation
|
||||
async function fetchInstallationData(params: any) {
|
||||
// Prepare query parameters for pagination and search
|
||||
const urlParams = new URLSearchParams({
|
||||
'page-number': params.page.toString(),
|
||||
'page-size': params.pageSize.toString(),
|
||||
})
|
||||
|
||||
if (params.q) {
|
||||
urlParams.append('search', params.q)
|
||||
}
|
||||
|
||||
return await xfetch(`/api/v1/patient?${urlParams.toString()}`)
|
||||
}
|
||||
|
||||
// Menggunakan composable untuk pagination
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationMeta,
|
||||
searchInput,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
fetchData: getInstallationList,
|
||||
} = usePaginatedList({
|
||||
fetchFn: fetchInstallationData,
|
||||
entityName: 'installation',
|
||||
})
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Instalasi',
|
||||
icon: 'i-lucide-box',
|
||||
refSearchNav: {
|
||||
placeholder: 'Cari (min. 3 karakter)...',
|
||||
minLength: 3,
|
||||
debounceMs: 500,
|
||||
showValidationFeedback: true,
|
||||
onInput: (_val: string) => {
|
||||
// Handle search input - this will be triggered by the header component
|
||||
},
|
||||
onClick: () => {
|
||||
// Handle search button click if needed
|
||||
},
|
||||
onClear: () => {
|
||||
// Handle search clear
|
||||
},
|
||||
},
|
||||
addNav: {
|
||||
label: 'Tambah Instalasi',
|
||||
icon: 'i-lucide-send',
|
||||
onClick: () => {
|
||||
isFormEntryDialogOpen.value = true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
// #endregion
|
||||
|
||||
// #region Functions
|
||||
|
||||
async function handleDeleteRow(record: any) {
|
||||
try {
|
||||
// TODO : hit backend request untuk delete
|
||||
console.log('Deleting record:', record)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch(`/api/v1/Installation/${record.id}`, {
|
||||
// method: 'DELETE'
|
||||
// })
|
||||
|
||||
// Refresh data setelah berhasil delete
|
||||
await getInstallationList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Record deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Error deleting record:', error)
|
||||
// TODO: Show error message
|
||||
} finally {
|
||||
// Reset record state
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion region
|
||||
|
||||
// #region Form event handlers
|
||||
|
||||
function onCancelForm(resetForm: () => void) {
|
||||
isFormEntryDialogOpen.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function onSubmitForm(values: any, resetForm: () => void) {
|
||||
let isSuccess = false
|
||||
try {
|
||||
// TODO: Implement form submission logic
|
||||
console.log('Form submitted:', values)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch('/api/v1/Installation', {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify(values)
|
||||
// })
|
||||
|
||||
// If successful, mark as success and close dialog
|
||||
isFormEntryDialogOpen.value = false
|
||||
isSuccess = true
|
||||
|
||||
// Refresh data after successful submission
|
||||
await getInstallationList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Installation created successfully')
|
||||
} catch (error: unknown) {
|
||||
console.warn('Error submitting form:', error)
|
||||
isSuccess = false
|
||||
// Don't close dialog or reset form on error
|
||||
// TODO: Show error message to user
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Watchers
|
||||
|
||||
// Watch for row actions
|
||||
watch(recId, () => {
|
||||
switch (recAction.value) {
|
||||
case ActionEvents.showEdit:
|
||||
// TODO: Handle edit action
|
||||
// isFormEntryDialogOpen.value = true
|
||||
break
|
||||
case ActionEvents.showConfirmDelete:
|
||||
// Trigger confirmation modal open
|
||||
isRecordConfirmationOpen.value = true
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle confirmation result
|
||||
function handleConfirmDelete(record: any, action: string) {
|
||||
console.log('Confirmed action:', action, 'for record:', record)
|
||||
handleDeleteRow(record)
|
||||
}
|
||||
|
||||
function handleCancelConfirmation() {
|
||||
// Reset record state when cancelled
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border p-4">
|
||||
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
|
||||
<AppInstallationList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
|
||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Instalasi" size="lg" prevent-outside>
|
||||
<AppInstallationEntryForm
|
||||
:installation="installationConf" :schema="schemaConf"
|
||||
:initial-values="{ name: '', code: '', encounterClassCode: '' }" @submit="onSubmitForm"
|
||||
@cancel="onCancelForm"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Record Confirmation Modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
<p v-if="record?.firstName"><strong>Nama:</strong> {{ record.firstName }}</p>
|
||||
<p v-if="record?.code"><strong>Kode:</strong> {{ record.cellphone }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</RecordConfirmation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* component style */
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const unitConf = {
|
||||
msg: {
|
||||
placeholder: '--- pilih instalasi',
|
||||
search: 'kode, nama instalasi',
|
||||
empty: 'instalasi tidak ditemukan',
|
||||
},
|
||||
items: [
|
||||
{ value: '1', label: 'Instalasi Medis', code: 'MED' },
|
||||
{ value: '2', label: 'Instalasi Keperawatan', code: 'NUR' },
|
||||
{ value: '3', label: 'Instalasi Administrasi', code: 'ADM' },
|
||||
{ value: '4', label: 'Instalasi Penunjang Non-Medis', code: 'SUP' },
|
||||
{ value: '5', label: 'Instalasi Pendidikan & Pelatihan', code: 'EDU' },
|
||||
{ value: '6', label: 'Instalasi Farmasi', code: 'PHA' },
|
||||
{ value: '7', label: 'Instalasi Radiologi', code: 'RAD' },
|
||||
{ value: '8', label: 'Instalasi Laboratorium', code: 'LAB' },
|
||||
{ value: '9', label: 'Instalasi Keuangan', code: 'FIN' },
|
||||
{ value: '10', label: 'Instalasi SDM', code: 'HR' },
|
||||
{ value: '11', label: 'Instalasi Teknologi Informasi', code: 'ITS' },
|
||||
{ value: '12', label: 'Instalasi Pemeliharaan & Sarana', code: 'MNT' },
|
||||
{ value: '13', label: 'Instalasi Gizi / Catering', code: 'CAT' },
|
||||
{ value: '14', label: 'Instalasi Keamanan', code: 'SEC' },
|
||||
{ value: '15', label: 'Instalasi Gawat Darurat', code: 'EMR' },
|
||||
{ value: '16', label: 'Instalasi Bedah Sentral', code: 'SUR' },
|
||||
{ value: '17', label: 'Instalasi Rawat Jalan', code: 'OUT' },
|
||||
{ value: '18', label: 'Instalasi Rawat Inap', code: 'INP' },
|
||||
{ value: '19', label: 'Instalasi Rehabilitasi Medik', code: 'REB' },
|
||||
{ value: '20', label: 'Instalasi Penelitian & Pengembangan', code: 'RSH' },
|
||||
],
|
||||
}
|
||||
|
||||
export const schemaConf = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Nama unit harus diisi',
|
||||
})
|
||||
.min(1, 'Nama unit harus diisi'),
|
||||
|
||||
code: z
|
||||
.string({
|
||||
required_error: 'Kode unit harus diisi',
|
||||
})
|
||||
.min(1, 'Kode unit harus diisi'),
|
||||
parentId: z.preprocess(
|
||||
(input: unknown) => {
|
||||
if (typeof input === 'string') {
|
||||
// Handle empty string case
|
||||
if (input.trim() === '') {
|
||||
return 0
|
||||
}
|
||||
return Number(input)
|
||||
}
|
||||
|
||||
return input
|
||||
},
|
||||
z
|
||||
.number({
|
||||
required_error: 'Instalasi induk harus dipilih',
|
||||
})
|
||||
.refine((num) => num > 0, 'Instalasi induk harus dipilih'),
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import AppUnitEntryForm from '~/components/app/unit/entry-form.vue'
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import RecordConfirmation from '~/components/pub/custom-ui/confirmation/record-confirmation.vue'
|
||||
import { ActionEvents } from '~/components/pub/custom-ui/data/types'
|
||||
import Header from '~/components/pub/custom-ui/nav-header/header.vue'
|
||||
import { usePaginatedList } from '~/composables/usePaginatedList'
|
||||
import { schemaConf, unitConf } from './entry'
|
||||
|
||||
// #region State & Computed
|
||||
// Dialog state
|
||||
const isFormEntryDialogOpen = ref(false)
|
||||
const isRecordConfirmationOpen = ref(false)
|
||||
|
||||
// Table action rowId provider
|
||||
const recId = ref<number>(0)
|
||||
const recAction = ref<string>('')
|
||||
const recItem = ref<any>(null)
|
||||
|
||||
// Fungsi untuk fetch data unit
|
||||
async function fetchUnitData(params: any) {
|
||||
// Prepare query parameters for pagination and search
|
||||
const urlParams = new URLSearchParams({
|
||||
'page-number': params.page.toString(),
|
||||
'page-size': params.pageSize.toString(),
|
||||
})
|
||||
|
||||
if (params.q) {
|
||||
urlParams.append('search', params.q)
|
||||
}
|
||||
|
||||
return await xfetch(`/api/v1/patient?${urlParams.toString()}`)
|
||||
}
|
||||
|
||||
// Menggunakan composable untuk pagination
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
paginationMeta,
|
||||
searchInput,
|
||||
handlePageChange,
|
||||
handleSearch,
|
||||
fetchData: getUnitList,
|
||||
} = usePaginatedList({
|
||||
fetchFn: fetchUnitData,
|
||||
entityName: 'unit',
|
||||
})
|
||||
|
||||
const headerPrep: HeaderPrep = {
|
||||
title: 'Unit',
|
||||
icon: 'i-lucide-box',
|
||||
refSearchNav: {
|
||||
placeholder: 'Cari (min. 3 karakter)...',
|
||||
minLength: 3,
|
||||
debounceMs: 500,
|
||||
showValidationFeedback: true,
|
||||
onInput: (_val: string) => {
|
||||
// Handle search input - this will be triggered by the header component
|
||||
},
|
||||
onClick: () => {
|
||||
// Handle search button click if needed
|
||||
},
|
||||
onClear: () => {
|
||||
// Handle search clear
|
||||
},
|
||||
},
|
||||
addNav: {
|
||||
label: 'Tambah Unit',
|
||||
icon: 'i-lucide-send',
|
||||
onClick: () => {
|
||||
isFormEntryDialogOpen.value = true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provide('rec_id', recId)
|
||||
provide('rec_action', recAction)
|
||||
provide('rec_item', recItem)
|
||||
provide('table_data_loader', isLoading)
|
||||
// #endregion
|
||||
|
||||
// #region Functions
|
||||
|
||||
async function handleDeleteRow(record: any) {
|
||||
try {
|
||||
// TODO : hit backend request untuk delete
|
||||
console.log('Deleting record:', record)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch(`/api/v1/unit/${record.id}`, {
|
||||
// method: 'DELETE'
|
||||
// })
|
||||
|
||||
// Refresh data setelah berhasil delete
|
||||
await getUnitList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Record deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Error deleting record:', error)
|
||||
// TODO: Show error message
|
||||
} finally {
|
||||
// Reset record state
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion region
|
||||
|
||||
// #region Form event handlers
|
||||
|
||||
function onCancelForm(resetForm: () => void) {
|
||||
isFormEntryDialogOpen.value = false
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function onSubmitForm(values: any, resetForm: () => void) {
|
||||
let isSuccess = false
|
||||
try {
|
||||
// TODO: Implement form submission logic
|
||||
console.log('Form submitted:', values)
|
||||
|
||||
// Simulate API call
|
||||
// const response = await xfetch('/api/v1/unit', {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify(values)
|
||||
// })
|
||||
|
||||
// If successful, mark as success and close dialog
|
||||
isFormEntryDialogOpen.value = false
|
||||
isSuccess = true
|
||||
|
||||
// Refresh data after successful submission
|
||||
await getUnitList()
|
||||
|
||||
// TODO: Show success message
|
||||
console.log('Unit created successfully')
|
||||
} catch (error: unknown) {
|
||||
console.warn('Error submitting form:', error)
|
||||
isSuccess = false
|
||||
// Don't close dialog or reset form on error
|
||||
// TODO: Show error message to user
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Watchers
|
||||
|
||||
// Watch for row actions
|
||||
watch(recId, () => {
|
||||
switch (recAction.value) {
|
||||
case ActionEvents.showEdit:
|
||||
// TODO: Handle edit action
|
||||
// isFormEntryDialogOpen.value = true
|
||||
break
|
||||
case ActionEvents.showConfirmDelete:
|
||||
// Trigger confirmation modal open
|
||||
isRecordConfirmationOpen.value = true
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle confirmation result
|
||||
function handleConfirmDelete(record: any, action: string) {
|
||||
console.log('Confirmed action:', action, 'for record:', record)
|
||||
handleDeleteRow(record)
|
||||
}
|
||||
|
||||
function handleCancelConfirmation() {
|
||||
// Reset record state when cancelled
|
||||
recId.value = 0
|
||||
recAction.value = ''
|
||||
recItem.value = null
|
||||
}
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border p-4">
|
||||
<Header v-model="searchInput" :prep="headerPrep" @search="handleSearch" />
|
||||
<AppUnitList :data="data" :pagination-meta="paginationMeta" @page-change="handlePageChange" />
|
||||
|
||||
<Dialog v-model:open="isFormEntryDialogOpen" title="Tambah Unit" size="lg" prevent-outside>
|
||||
<AppUnitEntryForm
|
||||
:unit="unitConf" :schema="schemaConf" :initial-values="{ name: '', code: '', parentId: '' }"
|
||||
@submit="onSubmitForm" @cancel="onCancelForm"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Record Confirmation Modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen" action="delete" :record="recItem"
|
||||
@confirm="handleConfirmDelete" @cancel="handleCancelConfirmation"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
<p v-if="record?.firstName"><strong>Nama:</strong> {{ record.firstName }}</p>
|
||||
<p v-if="record?.code"><strong>Kode:</strong> {{ record.cellphone }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</RecordConfirmation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* component style */
|
||||
</style>
|
||||
@@ -1,19 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableLoader } from './type'
|
||||
import type { Col, RecStrFuncComponent, RecStrFuncUnknown, Th } from '~/components/pub/custom-ui/data/types'
|
||||
import { Info } from 'lucide-vue-next'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/pub/ui/table'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
skeletonSize?: number
|
||||
rows: unknown[]
|
||||
cols: any[]
|
||||
header: any[]
|
||||
cols: Col[]
|
||||
header: Th[][]
|
||||
keys: string[]
|
||||
funcParsed: Record<string, (row: any) => any>
|
||||
funcHtml: Record<string, (row: any) => string>
|
||||
funcComponent: Record<string, (row: any, idx: number) => any>
|
||||
funcParsed: RecStrFuncUnknown
|
||||
funcHtml: RecStrFuncUnknown
|
||||
funcComponent: RecStrFuncComponent
|
||||
}>()
|
||||
|
||||
const getSkeletonSize = computed(() => {
|
||||
return props.skeletonSize || 5
|
||||
})
|
||||
const loader = inject('table_data_loader') as DataTableLoader
|
||||
|
||||
function handleActionCellClick(event: Event, _cellRef: string) {
|
||||
// Prevent event if clicked directly on the button/dropdown
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[role="button"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the dropdown trigger button and click it
|
||||
const cell = event.currentTarget as HTMLElement
|
||||
const triggerButton = cell.querySelector('button[data-state]') || cell.querySelector('button')
|
||||
if (triggerButton) {
|
||||
(triggerButton as HTMLButtonElement).click()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,7 +51,7 @@ v-for="(h, idx) in header[0]" :key="`head-${idx}`" class="border"
|
||||
|
||||
<TableBody v-if="loader.isTableLoading">
|
||||
<!-- Loading state with 5 skeleton rows -->
|
||||
<TableRow v-for="n in 5" :key="`skeleton-${n}`">
|
||||
<TableRow v-for="n in getSkeletonSize" :key="`skeleton-${n}`">
|
||||
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-skel-${n}-${cellIndex}`" class="border">
|
||||
<Skeleton class="bg-gray-100 animate-pulse text-muted-foreground w-full h-6" />
|
||||
</TableCell>
|
||||
@@ -49,19 +69,29 @@ v-for="(h, idx) in header[0]" :key="`head-${idx}`" class="border"
|
||||
</TableBody>
|
||||
<TableBody v-else>
|
||||
<TableRow v-for="(row, rowIndex) in rows" :key="`row-${rowIndex}`">
|
||||
<TableCell v-for="(key, cellIndex) in keys" :key="`cell-${rowIndex}-${cellIndex}`" class="border">
|
||||
<TableCell
|
||||
v-for="(key, cellIndex) in keys"
|
||||
:key="`cell-${rowIndex}-${cellIndex}`"
|
||||
class="border"
|
||||
:class="{ 'cursor-pointer': key === 'action' && funcComponent[key] }"
|
||||
@click="key === 'action' && funcComponent[key] ? handleActionCellClick($event, `cell-${rowIndex}-${cellIndex}`) : null"
|
||||
>
|
||||
<!-- If funcComponent has a renderer -->
|
||||
<component
|
||||
:is="funcComponent[key](row, rowIndex).component" v-if="funcComponent[key]"
|
||||
v-bind="funcComponent[key](row, rowIndex)"
|
||||
/>
|
||||
:is="funcComponent[key]?.(row, rowIndex).component"
|
||||
v-if="funcComponent[key]"
|
||||
:ref="key === 'action' ? `actionComponent-${rowIndex}-${cellIndex}` : undefined"
|
||||
:rec="row"
|
||||
:idx="rowIndex"
|
||||
v-bind="funcComponent[key]?.(row, rowIndex).props"
|
||||
/>
|
||||
<!-- If funcParsed or funcHtml returns a value -->
|
||||
<template v-else>
|
||||
<!-- Use v-html for funcHtml to render HTML content -->
|
||||
<div v-if="funcHtml[key]" v-html="funcHtml[key]?.(row)"></div>
|
||||
<div v-if="funcHtml[key]" v-html="funcHtml[key]?.(row, rowIndex)"></div>
|
||||
<!-- Use normal interpolation for funcParsed and regular data -->
|
||||
<template v-else>
|
||||
{{ funcParsed[key]?.(row) ?? (row as any)[key] }}
|
||||
{{ funcParsed[key]?.(row, rowIndex) ?? (row as any)[key] }}
|
||||
</template>
|
||||
</template>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from '~/components/pub/ui/dialog'
|
||||
|
||||
interface DialogProps {
|
||||
title: string
|
||||
description?: string
|
||||
preventOutside?: boolean
|
||||
open?: boolean
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DialogProps>(), {
|
||||
preventOutside: false,
|
||||
open: false,
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
// Computed untuk menentukan class size berdasarkan prop size
|
||||
const sizeClass = computed(() => {
|
||||
const sizeMap = {
|
||||
sm: 'sm:max-w-[350px]',
|
||||
md: 'sm:max-w-[425px]',
|
||||
lg: 'sm:max-w-[600px]',
|
||||
xl: 'sm:max-w-[800px]',
|
||||
full: 'sm:max-w-[95vw]',
|
||||
}
|
||||
return sizeMap[props.size]
|
||||
})
|
||||
|
||||
// Computed untuk state dialog
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent
|
||||
:class="sizeClass" @interact-outside="(e: any) => preventOutside && e.preventDefault()"
|
||||
@pointer-down-outside="(e: any) => preventOutside && e.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from '~/components/pub/ui/dialog'
|
||||
|
||||
interface DialogProps {
|
||||
title: string
|
||||
description?: string
|
||||
preventOutside?: boolean
|
||||
open?: boolean
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
}
|
||||
const props = withDefaults(defineProps<DialogProps>(), {
|
||||
preventOutside: false,
|
||||
open: false,
|
||||
size: 'md',
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizeMap = {
|
||||
sm: 'sm:max-w-[350px]',
|
||||
md: 'sm:max-w-[425px]',
|
||||
lg: 'sm:max-w-[600px]',
|
||||
xl: 'sm:max-w-[800px]',
|
||||
full: 'sm:max-w-[95vw]',
|
||||
}
|
||||
return sizeMap[props.size]
|
||||
})
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent
|
||||
:class="sizeClass"
|
||||
@interact-outside="(e: any) => preventOutside && e.preventDefault()"
|
||||
@pointer-down-outside="(e: any) => preventOutside && e.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ props.title }}</DialogTitle>
|
||||
<DialogDescription v-if="props.description">{{ props.description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
# Confirmation Modal Components
|
||||
|
||||
Sistem confirmation modal yang modular dan dapat digunakan kembali di seluruh aplikasi.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `confirmation.vue` - Base Confirmation Modal
|
||||
|
||||
Komponen dasar untuk modal konfirmasi yang dapat dikustomisasi.
|
||||
|
||||
**Props:**
|
||||
- `open?: boolean` - Status modal (terbuka/tertutup)
|
||||
- `title?: string` - Judul modal (default: "Konfirmasi")
|
||||
- `message?: string` - Pesan konfirmasi (default: "Apakah Anda yakin ingin melanjutkan?")
|
||||
- `confirmText?: string` - Text tombol konfirmasi (default: "Ya")
|
||||
- `cancelText?: string` - Text tombol batal (default: "Batal")
|
||||
- `variant?: 'default' | 'destructive' | 'warning'` - Varian tampilan (default: "default")
|
||||
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Ukuran modal (default: "md")
|
||||
|
||||
**Events:**
|
||||
- `@confirm` - Dipanggil saat tombol konfirmasi diklik
|
||||
- `@cancel` - Dipanggil saat tombol batal diklik
|
||||
- `@update:open` - Dipanggil saat status modal berubah
|
||||
|
||||
**Contoh penggunaan:**
|
||||
```vue
|
||||
<template>
|
||||
<Confirmation
|
||||
v-model:open="isConfirmOpen"
|
||||
title="Hapus Data"
|
||||
message="Apakah Anda yakin ingin menghapus data ini?"
|
||||
confirm-text="Hapus"
|
||||
variant="destructive"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. `record-confirmation.vue` - Record-specific Confirmation
|
||||
|
||||
Komponen khusus untuk konfirmasi operasi pada record/data tertentu.
|
||||
|
||||
**Props:**
|
||||
- `open?: boolean` - Status modal
|
||||
- `action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'` - Jenis aksi (default: "delete")
|
||||
- `record?: RecordData | null` - Data record yang akan diproses
|
||||
- `customTitle?: string` - Custom judul (opsional)
|
||||
- `customMessage?: string` - Custom pesan (opsional)
|
||||
- `customConfirmText?: string` - Custom text tombol konfirmasi (opsional)
|
||||
- `customCancelText?: string` - Custom text tombol batal (opsional)
|
||||
|
||||
**Events:**
|
||||
- `@confirm` - Dipanggil dengan parameter `(record, action)`
|
||||
- `@cancel` - Dipanggil saat batal
|
||||
- `@update:open` - Update status modal
|
||||
|
||||
**Contoh penggunaan:**
|
||||
```vue
|
||||
<template>
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmOpen"
|
||||
action="delete"
|
||||
:record="selectedRecord"
|
||||
@confirm="handleDeleteRecord"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
<div class="text-sm">
|
||||
<p><strong>ID:</strong> {{ record?.id }}</p>
|
||||
<p><strong>Nama:</strong> {{ record?.name }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</RecordConfirmation>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Action Types
|
||||
|
||||
Record confirmation mendukung beberapa jenis aksi dengan konfigurasi default:
|
||||
|
||||
- **delete**: Hapus data (variant: destructive, warna merah)
|
||||
- **deactivate**: Nonaktifkan data (variant: warning, warna kuning)
|
||||
- **activate**: Aktifkan data (variant: default, warna biru)
|
||||
- **archive**: Arsipkan data (variant: warning, warna kuning)
|
||||
- **restore**: Pulihkan data (variant: default, warna biru)
|
||||
|
||||
## Integration Example
|
||||
|
||||
Contoh implementasi di komponen list:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// State management
|
||||
const isRecordConfirmationOpen = ref(false)
|
||||
const selectedRecord = ref(null)
|
||||
const confirmAction = ref('delete')
|
||||
|
||||
// Handle action dari table
|
||||
function handleRowAction(action, record) {
|
||||
selectedRecord.value = record
|
||||
confirmAction.value = action
|
||||
isRecordConfirmationOpen.value = true
|
||||
}
|
||||
|
||||
// Handle konfirmasi
|
||||
async function handleConfirmAction(record, action) {
|
||||
try {
|
||||
// API call berdasarkan action
|
||||
await performAction(action, record.id)
|
||||
// Refresh data
|
||||
await refreshData()
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Your list component -->
|
||||
<DataTable @action="handleRowAction" />
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<RecordConfirmation
|
||||
v-model:open="isRecordConfirmationOpen"
|
||||
:action="confirmAction"
|
||||
:record="selectedRecord"
|
||||
@confirm="handleConfirmAction"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Komponen menggunakan Tailwind CSS dan shadcn/ui components. Pastikan dependencies berikut tersedia:
|
||||
- `~/components/pub/custom-ui/modal/dialog.vue`
|
||||
- `~/components/pub/ui/button`
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from '~/components/pub/base/modal/dialog.vue'
|
||||
import { Button } from '~/components/pub/ui/button'
|
||||
|
||||
interface ConfirmationProps {
|
||||
open?: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'default' | 'destructive' | 'warning'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
interface ConfirmationEmits {
|
||||
'update:open': [value: boolean]
|
||||
'confirm': []
|
||||
'cancel': []
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ConfirmationProps>(), {
|
||||
open: false,
|
||||
title: 'Konfirmasi',
|
||||
message: 'Apakah Anda yakin ingin melanjutkan?',
|
||||
confirmText: 'Ya',
|
||||
cancelText: 'Batal',
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<ConfirmationEmits>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variants = {
|
||||
default: {
|
||||
icon: 'i-lucide-help-circle',
|
||||
iconColor: 'text-blue-500',
|
||||
confirmVariant: 'default' as const,
|
||||
},
|
||||
destructive: {
|
||||
icon: 'i-lucide-alert-triangle',
|
||||
iconColor: 'text-red-500',
|
||||
confirmVariant: 'destructive' as const,
|
||||
},
|
||||
warning: {
|
||||
icon: 'i-lucide-alert-circle',
|
||||
iconColor: 'text-yellow-500',
|
||||
confirmVariant: 'default' as const,
|
||||
},
|
||||
}
|
||||
return variants[props.variant]
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen" :title="title" :size="size">
|
||||
<div class="space-y-4">
|
||||
<!-- Icon dan pesan -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div :class="[variantClasses.icon, variantClasses.iconColor]" class="w-6 h-6 mt-1 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot untuk konten custom -->
|
||||
<div v-if="$slots.default">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button :variant="variantClasses.confirmVariant" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import Confirmation from '~/components/pub/custom-ui/confirmation/confirmation.vue'
|
||||
|
||||
interface RecordData {
|
||||
id: number | string
|
||||
name?: string
|
||||
title?: string
|
||||
code?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface RecordConfirmationProps {
|
||||
open?: boolean
|
||||
action?: 'delete' | 'deactivate' | 'activate' | 'archive' | 'restore'
|
||||
record?: RecordData | null
|
||||
customTitle?: string
|
||||
customMessage?: string
|
||||
customConfirmText?: string
|
||||
customCancelText?: string
|
||||
}
|
||||
|
||||
interface RecordConfirmationEmits {
|
||||
'update:open': [value: boolean]
|
||||
'confirm': [record: RecordData, action: string]
|
||||
'cancel': []
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<RecordConfirmationProps>(), {
|
||||
open: false,
|
||||
action: 'delete',
|
||||
record: null,
|
||||
customTitle: '',
|
||||
customMessage: '',
|
||||
customConfirmText: '',
|
||||
customCancelText: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<RecordConfirmationEmits>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
|
||||
const actionConfig = computed(() => {
|
||||
const configs = {
|
||||
delete: {
|
||||
title: 'Hapus Data',
|
||||
message: 'Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan.',
|
||||
confirmText: 'Hapus',
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
deactivate: {
|
||||
title: 'Nonaktifkan Data',
|
||||
message: 'Apakah Anda yakin ingin menonaktifkan data ini?',
|
||||
confirmText: 'Nonaktifkan',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
activate: {
|
||||
title: 'Aktifkan Data',
|
||||
message: 'Apakah Anda yakin ingin mengaktifkan data ini?',
|
||||
confirmText: 'Aktifkan',
|
||||
variant: 'default' as const,
|
||||
},
|
||||
archive: {
|
||||
title: 'Arsipkan Data',
|
||||
message: 'Apakah Anda yakin ingin mengarsipkan data ini?',
|
||||
confirmText: 'Arsipkan',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
restore: {
|
||||
title: 'Pulihkan Data',
|
||||
message: 'Apakah Anda yakin ingin memulihkan data ini?',
|
||||
confirmText: 'Pulihkan',
|
||||
variant: 'default' as const,
|
||||
},
|
||||
}
|
||||
return configs[props.action]
|
||||
})
|
||||
|
||||
const finalTitle = computed(() => {
|
||||
return props.customTitle || actionConfig.value.title
|
||||
})
|
||||
|
||||
const finalMessage = computed(() => {
|
||||
if (props.customMessage) {
|
||||
return props.customMessage
|
||||
}
|
||||
|
||||
const baseMessage = actionConfig.value.message
|
||||
// if (props.record) {
|
||||
// const recordName = props.record.name || props.record.title || props.record.code || `ID: ${props.record.id}`
|
||||
// return `${baseMessage}\n\nData: ${recordName}`
|
||||
// }
|
||||
|
||||
return baseMessage
|
||||
})
|
||||
|
||||
const finalConfirmText = computed(() => {
|
||||
return props.customConfirmText || actionConfig.value.confirmText
|
||||
})
|
||||
|
||||
const finalCancelText = computed(() => {
|
||||
return props.customCancelText || 'Batal'
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.record) {
|
||||
emit('confirm', props.record, props.action)
|
||||
}
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Confirmation
|
||||
v-model:open="isOpen" :title="finalTitle" :message="finalMessage" :confirm-text="finalConfirmText"
|
||||
:cancel-text="finalCancelText" :variant="actionConfig.variant" size="md" @confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- Slot untuk informasi tambahan record -->
|
||||
<div v-if="record && $slots.default" class="mt-4 p-3 bg-muted rounded-md">
|
||||
<slot :record="record" :action="action" />
|
||||
</div>
|
||||
</Confirmation>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,13 +12,13 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,19 +12,19 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
rec: ListItemDto
|
||||
@@ -11,19 +12,19 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function detail() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showDetail'
|
||||
recAction.value = ActionEvents.showDetail
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkItem, ListItemDto } from './types'
|
||||
import { ActionEvents } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
interface Props {
|
||||
rec: ListItemDto
|
||||
}>()
|
||||
size?: 'default' | 'sm' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
const recId = inject<Ref<number>>('rec_id')!
|
||||
const recAction = inject<Ref<string>>('rec_action')!
|
||||
@@ -11,13 +17,13 @@ const recItem = inject<Ref<any>>('rec_item')!
|
||||
|
||||
function edit() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showEdit'
|
||||
recAction.value = ActionEvents.showEdit
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
function del() {
|
||||
recId.value = props.rec.id || 0
|
||||
recAction.value = 'showConfirmDel'
|
||||
recAction.value = ActionEvents.showConfirmDelete
|
||||
recItem.value = props.rec
|
||||
}
|
||||
|
||||
@@ -44,7 +50,7 @@ const linkItems: LinkItem[] = [
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
:size="size"
|
||||
class="data-[state=open]:text-sidebar-accent-foreground data-[state=open]:bg-white"
|
||||
>
|
||||
<Icon name="i-lucide-chevrons-up-down" class="ml-auto size-4" />
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface Th {
|
||||
}
|
||||
|
||||
export interface ButtonNav {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'negative' | 'ghost' | 'link'
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
|
||||
classVal?: string
|
||||
classValExt?: string
|
||||
icon?: string
|
||||
@@ -56,6 +56,12 @@ export interface QuickSearchNav {
|
||||
}
|
||||
|
||||
export interface RefSearchNav {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
minLength?: number
|
||||
debounceMs?: number
|
||||
inputClass?: string
|
||||
showValidationFeedback?: boolean
|
||||
onInput: (val: string) => void
|
||||
onClick: () => void
|
||||
onClear: () => void
|
||||
@@ -94,3 +100,9 @@ export interface LinkItem {
|
||||
onClick?: (event: Event) => void
|
||||
headerStatus?: boolean
|
||||
}
|
||||
|
||||
export const ActionEvents = {
|
||||
showConfirmDelete: 'showConfirmDel',
|
||||
showEdit: 'showEdit',
|
||||
showDetail: 'showDetail',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
label: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
modelValue?: string
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const selectedItem = computed(() =>
|
||||
props.items.find(item => item.value === props.modelValue),
|
||||
)
|
||||
|
||||
const displayText = computed(() =>
|
||||
selectedItem.value?.label || props.placeholder || '---pilih item',
|
||||
)
|
||||
|
||||
// Create searchable items with combined code and label for better search
|
||||
// Sort by:
|
||||
// 1. Selected item first (highest priority)
|
||||
// 2. Then by label alphabetically
|
||||
const searchableItems = computed(() => {
|
||||
const itemsWithSearch = props.items.map(item => ({
|
||||
...item,
|
||||
searchValue: `${item.code || ''} ${item.label}`.trim(),
|
||||
isSelected: item.value === props.modelValue,
|
||||
}))
|
||||
|
||||
return itemsWithSearch.sort((a, b) => {
|
||||
// Selected item always comes first
|
||||
if (a.isSelected && !b.isSelected) return -1
|
||||
if (!a.isSelected && b.isSelected) return 1
|
||||
|
||||
// If neither or both are selected, sort by label alphabetically
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
})
|
||||
|
||||
function onSelect(item: Item) {
|
||||
emit('update:modelValue', item.value)
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
:id="props.id"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="`${props.id}-list`"
|
||||
:aria-describedby="`${props.id}-search`"
|
||||
:class="cn(
|
||||
'w-full justify-between border-black bg-white hover:bg-gray-50 text-sm font-normal',
|
||||
!modelValue && 'text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
{{ displayText }}
|
||||
<Icon name="i-lucide-chevrons-up-down" class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
:id="`${props.id}-search`"
|
||||
class="h-9"
|
||||
:placeholder="searchPlaceholder || 'Cari...'"
|
||||
:aria-label="`Cari ${displayText}`"
|
||||
/>
|
||||
<CommandEmpty>{{ emptyMessage || 'Item tidak ditemukan.' }}</CommandEmpty>
|
||||
<CommandList :id="`${props.id}-list`" role="listbox">
|
||||
<CommandGroup>
|
||||
<CommandItem v-for="item in searchableItems" :key="item.value" :value="item.searchValue" @select="onSelect(item)">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="item.code" class="text-xs text-muted-foreground">{{ item.code }}</span>
|
||||
<Icon
|
||||
name="i-lucide-check"
|
||||
:class="cn(
|
||||
'h-4 w-4',
|
||||
modelValue === item.value ? 'opacity-100' : 'opacity-0',
|
||||
)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
export interface XError {
|
||||
message: string
|
||||
[key: string]: any
|
||||
}
|
||||
import type { XErrors } from '~/types/error'
|
||||
|
||||
defineProps<{
|
||||
id?: string
|
||||
errors?: Record<string, XError>
|
||||
errors?: XErrors
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -14,7 +11,7 @@ defineProps<{
|
||||
<div class="grow">
|
||||
<slot />
|
||||
<div v-if="id && errors?.[id]" class="field-error-info">
|
||||
{{ errors[id].message }}
|
||||
{{ errors[id]?.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labelFor: string
|
||||
size?: 'default' | 'narrow' | 'wide'
|
||||
height?: 'default' | 'compact'
|
||||
position?: 'default' | 'dynamic'
|
||||
@@ -45,7 +46,7 @@ const labelClass = computed(() => positionChildMap[props.position])
|
||||
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<label :class="labelClass">
|
||||
<label :class="labelClass" :for="labelFor">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectRoot } from 'radix-vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '~/components/pub/ui/select'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
label: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
label?: string
|
||||
separator?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Sort items with selected item first, then alphabetically
|
||||
const sortedItems = computed(() => {
|
||||
const itemsWithSelection = props.items.map(item => ({
|
||||
...item,
|
||||
isSelected: item.value === props.modelValue,
|
||||
}))
|
||||
|
||||
return itemsWithSelection.sort((a, b) => {
|
||||
// Selected item always comes first
|
||||
if (a.isSelected && !b.isSelected) return -1
|
||||
if (!a.isSelected && b.isSelected) return 1
|
||||
|
||||
// If neither or both are selected, sort by label alphabetically
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
})
|
||||
|
||||
function onValueChange(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot :model-value="modelValue" @update:model-value="onValueChange">
|
||||
<SelectTrigger :class="cn('w-full', props.class)">
|
||||
<SelectValue :placeholder="placeholder || 'Pilih item'" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel v-if="label">
|
||||
{{ label }}
|
||||
</SelectLabel>
|
||||
|
||||
<SelectItem
|
||||
v-for="item in sortedItems"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.code" class="text-xs text-muted-foreground ml-2">
|
||||
{{ item.code }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectSeparator v-if="separator" />
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -1,18 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeaderPrep, RefSearchNav } from '~/components/pub/custom-ui/data/types'
|
||||
import type { HeaderPrep } from '~/components/pub/custom-ui/data/types'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
prep: HeaderPrep
|
||||
refSearchNav: RefSearchNav
|
||||
modelValue?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'search': [value: string]
|
||||
}>()
|
||||
|
||||
// Internal search state
|
||||
const searchInput = ref(props.modelValue || '')
|
||||
const debouncedSearch = refDebounced(searchInput, props.prep.refSearchNav?.debounceMs || 500)
|
||||
|
||||
// Computed search model for v-model
|
||||
const searchModel = computed({
|
||||
get: () => searchInput.value,
|
||||
set: (value: string) => {
|
||||
searchInput.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue !== searchInput.value) {
|
||||
searchInput.value = newValue || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Watch debounced search and emit search event
|
||||
watch(debouncedSearch, (newValue) => {
|
||||
const minLength = props.prep.refSearchNav?.minLength || 3
|
||||
// Only search if meets minimum length or empty (to clear search)
|
||||
if (newValue.length === 0 || newValue.length >= minLength) {
|
||||
emit('search', newValue)
|
||||
props.prep.refSearchNav?.onInput(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clear search
|
||||
function clearSearch() {
|
||||
searchModel.value = ''
|
||||
props.prep.refSearchNav?.onClear()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 text-lg font-bold text-gray-900">
|
||||
<Icon :name="props.prep.icon!" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ props.prep.title }}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 text-lg font-bold text-gray-900">
|
||||
<Icon :name="props.prep.icon!" class="mr-2 size-4 md:size-6 align-middle" />
|
||||
{{ props.prep.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<!-- Search Section -->
|
||||
<div v-if="props.prep.refSearchNav" class="ml-3 text-lg text-gray-900 relative">
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="search-table"
|
||||
v-model="searchModel"
|
||||
name="search-table"
|
||||
type="text"
|
||||
class="w-full rounded-md border bg-white px-4 py-2 text-gray-900 sm:text-sm"
|
||||
:class="[
|
||||
props.prep.refSearchNav.inputClass,
|
||||
{
|
||||
'border-amber-300 bg-amber-50': searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3),
|
||||
'border-green-300 bg-green-50': searchInput.length >= (props.prep.refSearchNav.minLength || 3),
|
||||
},
|
||||
]"
|
||||
:placeholder="props.prep.refSearchNav.placeholder || 'Cari (min. 3 karakter)...'"
|
||||
/>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="searchInput.length > 0"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<Icon name="i-lucide-x" class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Validation feedback -->
|
||||
<div
|
||||
v-if="props.prep.refSearchNav.showValidationFeedback !== false && searchInput.length > 0 && searchInput.length < (props.prep.refSearchNav.minLength || 3)"
|
||||
class="absolute -bottom-6 left-0 text-xs text-amber-600 whitespace-nowrap"
|
||||
>
|
||||
Minimal {{ props.prep.refSearchNav.minLength || 3 }} karakter untuk mencari
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Button -->
|
||||
<div v-if="props.prep.addNav" class="m-2 flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-white sm:text-sm"
|
||||
:class="props.prep.addNav.classVal"
|
||||
:variant="(props.prep.addNav.variant as any) || 'default'"
|
||||
@click="props.prep.addNav?.onClick"
|
||||
>
|
||||
<Icon :name="props.prep.addNav.icon || 'i-lucide-plus'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ props.prep.addNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Button -->
|
||||
<div v-if="props.prep.filterNav" class="m-2 flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
|
||||
:class="props.prep.filterNav.classVal"
|
||||
:variant="(props.prep.filterNav.variant as any) || 'default'"
|
||||
@click="props.prep.filterNav?.onClick"
|
||||
>
|
||||
<Icon :name="props.prep.filterNav.icon || 'i-lucide-filter'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ props.prep.filterNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Print Button -->
|
||||
<div v-if="props.prep.printNav" class="m-2 flex items-center">
|
||||
<Button
|
||||
class="rounded-md border border-gray-300 px-4 py-2 sm:text-sm"
|
||||
:class="props.prep.printNav.classVal"
|
||||
:variant="(props.prep.printNav.variant as any) || 'default'"
|
||||
@click="props.prep.printNav?.onClick"
|
||||
>
|
||||
<Icon :name="props.prep.printNav.icon || 'i-lucide-printer'" class="mr-2 h-4 w-4 align-middle" />
|
||||
{{ props.prep.printNav.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationMeta } from './pagination.type'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
} from '~/components/pub/ui/pagination'
|
||||
|
||||
interface Props {
|
||||
paginationMeta: PaginationMeta
|
||||
onPageChange?: (page: number) => void
|
||||
showInfo?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
onPageChange: undefined,
|
||||
showInfo: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
}>()
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
if (props.onPageChange) {
|
||||
props.onPageChange(page)
|
||||
}
|
||||
emit('pageChange', page)
|
||||
}
|
||||
|
||||
function handleFirst() {
|
||||
if (props.paginationMeta.hasPrev) {
|
||||
handlePageChange(1)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (props.paginationMeta.hasPrev) {
|
||||
handlePageChange(props.paginationMeta.page - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (props.paginationMeta.hasNext) {
|
||||
handlePageChange(props.paginationMeta.page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLast() {
|
||||
if (props.paginationMeta.hasNext) {
|
||||
handlePageChange(props.paginationMeta.totalPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties for formatted numbers
|
||||
const formattedRecordCount = computed(() => {
|
||||
const count = props.paginationMeta.recordCount
|
||||
if (count == null || count === undefined) return '0'
|
||||
return Number(count).toLocaleString('id-ID')
|
||||
})
|
||||
|
||||
const startRecord = computed(() => {
|
||||
return ((props.paginationMeta.page - 1) * props.paginationMeta.pageSize) + 1
|
||||
})
|
||||
|
||||
const endRecord = computed(() => {
|
||||
return Math.min(props.paginationMeta.page * props.paginationMeta.pageSize, props.paginationMeta.recordCount)
|
||||
})
|
||||
|
||||
// Function to determine button width based on page number
|
||||
function getButtonClass(pageNumber: number) {
|
||||
const digits = pageNumber.toString().length
|
||||
|
||||
if (digits >= 4) { // 1000+ (1k+)
|
||||
return 'h-9 px-4 min-w-12'
|
||||
} else if (digits === 3) { // 100-999
|
||||
return 'h-9 px-3 min-w-10'
|
||||
} else { // 1-99
|
||||
return 'w-9 h-9 p-0'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2 py-2 w-full min-w-0">
|
||||
<!-- Info text -->
|
||||
<div v-if="showInfo" class="text-sm text-muted-foreground shrink-0">
|
||||
Menampilkan {{ startRecord }}
|
||||
hingga {{ endRecord }}
|
||||
dari {{ formattedRecordCount }} data
|
||||
</div>
|
||||
<div v-else class="shrink-0"></div>
|
||||
|
||||
<!-- Spacer untuk memastikan ada ruang di tengah -->
|
||||
<div class="flex-1 min-w-4"></div>
|
||||
|
||||
<!-- Pagination controls -->
|
||||
<div class="shrink-0">
|
||||
<Pagination
|
||||
v-slot="{ page }" :total="paginationMeta.recordCount" :sibling-count="1" :page="paginationMeta.page"
|
||||
:items-per-page="paginationMeta.pageSize" show-edges
|
||||
>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<PaginationFirst :disabled="!paginationMeta.hasPrev" @click="handleFirst" />
|
||||
<PaginationPrev :disabled="!paginationMeta.hasPrev" @click="handlePrev" />
|
||||
|
||||
<template v-for="(item, index) in items">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'" :key="index" :value="item.value" as-child
|
||||
@click="handlePageChange(item.value)"
|
||||
>
|
||||
<Button :class="getButtonClass(item.value)" :variant="item.value === page ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
|
||||
<PaginationNext :disabled="!paginationMeta.hasNext" @click="handleNext" />
|
||||
<PaginationLast :disabled="!paginationMeta.hasNext" @click="handleLast" />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { SelectRoot } from 'radix-vue'
|
||||
import type { SelectRootEmits, SelectRootProps } from 'radix-vue'
|
||||
import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
import {
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectValue,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '~/components/pub/ui/select'
|
||||
import type { SelectRootProps, SelectRootEmits } from 'radix-vue'
|
||||
import { useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
|
||||
@@ -32,8 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -21,14 +21,14 @@ const iconName = computed(() => props.iconName || 'i-radix-icons-caret-sort')
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'border-input ring-offset-background placeholder:text-muted-foreground flex h-10 w-full rounded-md border border-gray-400 px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-input ring-offset-background placeholder:text-muted-foreground relative flex h-10 w-full rounded-md border border-gray-400 pl-3 pr-8 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<Icon :name="iconName" class="h-4 w-4 opacity-50" />
|
||||
<SelectIcon as-child class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Icon name="i-radix-icons-caret-sort" class="h-4 w-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# useFormErrors Composable
|
||||
|
||||
Composable untuk menangani form validation errors seperti Laravel. Mengkonversi ZodError menjadi format yang mudah digunakan di template.
|
||||
|
||||
## Penggunaan
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
// Di component parent (entry.vue)
|
||||
const { errors, setFromZodError, clearErrors } = useFormErrors()
|
||||
|
||||
// Validasi dengan Zod
|
||||
const result = schema.safeParse(data.value)
|
||||
if (!result.success) {
|
||||
setFromZodError(result.error)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Di Template
|
||||
|
||||
```vue
|
||||
<!-- Pass errors ke form component -->
|
||||
<AppDivisonEntryForm v-model="data" :errors="errors" />
|
||||
```
|
||||
|
||||
### Di Form Component
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import type { FormErrors } from '~/composables/useFormErrors'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
errors?: FormErrors
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Setiap Field harus memiliki id yang sesuai dengan field name -->
|
||||
<Field id="name" :errors="errors">
|
||||
<Input v-model="data.name" />
|
||||
</Field>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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<FormErrors>({})
|
||||
|
||||
/**
|
||||
* 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<string, any> = {}) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<typeof defaultQuerySchema>
|
||||
|
||||
interface UsePaginatedListOptions<T = any> {
|
||||
// Schema untuk validasi query parameters
|
||||
querySchema?: z.ZodSchema<any>
|
||||
defaultQuery?: Record<string, any>
|
||||
// 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<T = any>(options: UsePaginatedListOptions<T>) {
|
||||
const {
|
||||
querySchema = defaultQuerySchema,
|
||||
defaultQuery = defaultQueryParams,
|
||||
fetchFn,
|
||||
entityName,
|
||||
} = options
|
||||
|
||||
// State management
|
||||
const data = ref<T[]>([])
|
||||
const isLoading = reactive<DataTableLoader>({
|
||||
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<PaginationMeta>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
export interface XError {
|
||||
code: string
|
||||
message: string
|
||||
expectedVal?: string
|
||||
givenVal?: string
|
||||
}
|
||||
|
||||
export type XErrors = Record<string, XError>
|
||||
import type { XError, XErrors } from '~/types/error'
|
||||
|
||||
export interface XfetchResult {
|
||||
success: boolean
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface ItemPrice {
|
||||
id: string
|
||||
item_id: number
|
||||
price: number
|
||||
insuranceCompany_code: string
|
||||
}
|
||||
|
||||
export interface CreateDto {
|
||||
item_id: number
|
||||
price: number
|
||||
insuranceCompany_code: string
|
||||
}
|
||||
|
||||
export interface GetListDto {
|
||||
page: number
|
||||
size: number
|
||||
name?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface GetDetailDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface UpdateDto extends CreateDto {
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface DeleteDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function genMedicine(): CreateDto {
|
||||
return {
|
||||
item_id: 1,
|
||||
price: 1,
|
||||
insuranceCompany_code: 'test',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface Item {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
itemGroup_code: string
|
||||
uom_code: string
|
||||
infra_id: number
|
||||
stock: number
|
||||
}
|
||||
|
||||
export interface CreateDto {
|
||||
name: string
|
||||
code: string
|
||||
itemGroup_code: string
|
||||
uom_code: string
|
||||
infra_id: number
|
||||
stock: number
|
||||
}
|
||||
|
||||
export interface GetListDto {
|
||||
page: number
|
||||
size: number
|
||||
name?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface GetDetailDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface UpdateDto extends CreateDto {
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface DeleteDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function genMedicine(): CreateDto {
|
||||
return {
|
||||
name: 'test',
|
||||
code: 'test',
|
||||
itemGroup_code: 'test',
|
||||
uom_code: 'test',
|
||||
infra_id: 1,
|
||||
stock: 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface MedicineGroup {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface CreateDto {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface GetListDto {
|
||||
page: number
|
||||
size: number
|
||||
name?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface GetDetailDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface UpdateDto extends CreateDto {
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface DeleteDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function genMedicine(): CreateDto {
|
||||
return {
|
||||
name: 'name',
|
||||
code: 'code',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface MedicineMethod {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface CreateDto {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface GetListDto {
|
||||
page: number
|
||||
size: number
|
||||
name?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface GetDetailDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface UpdateDto extends CreateDto {
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface DeleteDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function genMedicine(): CreateDto {
|
||||
return {
|
||||
name: 'name',
|
||||
code: 'code',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface Medicine {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
medicineGroup_code: string
|
||||
medicineMethod_code: string
|
||||
uom_code: string
|
||||
type: string
|
||||
dose: string
|
||||
infra_id: string
|
||||
stock: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface CreateDto {
|
||||
name: string
|
||||
code: string
|
||||
medicineGroup_code: string
|
||||
medicineMethod_code: string
|
||||
uom_code: string
|
||||
type: string
|
||||
dose: string
|
||||
infra_id: string
|
||||
stock: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface GetListDto {
|
||||
page: number
|
||||
size: number
|
||||
name?: string
|
||||
code?: string
|
||||
medicineGroup_code?: string
|
||||
medicineMethod_code?: string
|
||||
uom_code?: string
|
||||
type?: string
|
||||
dose?: string
|
||||
infra_id?: string
|
||||
stock?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface GetDetailDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface UpdateDto extends CreateDto {
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface DeleteDto {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function genMedicine(): CreateDto {
|
||||
return {
|
||||
name: 'name',
|
||||
code: 'code',
|
||||
medicineGroup_code: 'medicineGroup_code',
|
||||
medicineMethod_code: 'medicineMethod_code',
|
||||
uom_code: 'uom_code',
|
||||
type: 'type',
|
||||
dose: 'dose',
|
||||
infra_id: 'infra_id',
|
||||
stock: 'stock',
|
||||
status: 'status',
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const canCreate = hasCreateAccess(roleAccess)
|
||||
|
||||
<template>
|
||||
<div v-if="canCreate">
|
||||
<FlowDoctorAdd />
|
||||
<ContentDoctorAdd />
|
||||
</div>
|
||||
<Error v-else :status-code="403" />
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user