DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import * as select from '@zag-js/select'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createHeir, deleteHeir, listHeirs, updateHeir } from '@/modules/profile/services/heir.service'
|
||||
import type { Heir, HeirPayload } from '@/modules/profile/types/heir.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const heirs = ref<Heir[]>([])
|
||||
const loadingHeirs = ref(false)
|
||||
const savingHeir = ref(false)
|
||||
const deletingHeirId = ref<string | null>(null)
|
||||
const editingHeirId = ref<string | null>(null)
|
||||
|
||||
type HeirFieldKey =
|
||||
| 'name'
|
||||
| 'ic_number'
|
||||
| 'relationship'
|
||||
| 'phone_number'
|
||||
| 'address'
|
||||
| 'is_primary'
|
||||
|
||||
const HEIR_FIELD_KEYS: HeirFieldKey[] = [
|
||||
'name',
|
||||
'ic_number',
|
||||
'relationship',
|
||||
'phone_number',
|
||||
'address',
|
||||
'is_primary',
|
||||
]
|
||||
|
||||
const heirErrors = reactive<Partial<Record<HeirFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Isteri', value: 'Isteri' },
|
||||
{ label: 'Suami', value: 'Suami' },
|
||||
{ label: 'Anak', value: 'Anak' },
|
||||
{ label: 'Bapa', value: 'Bapa' },
|
||||
{ label: 'Ibu', value: 'Ibu' },
|
||||
{ label: 'Orang Tua', value: 'Orang Tua' },
|
||||
{ label: 'Saudara', value: 'Saudara' },
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
itemToValue: (item) => item.label,
|
||||
})
|
||||
}
|
||||
|
||||
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
||||
if (!label) return null
|
||||
return options.find((option) => option.label === label)?.value ?? null
|
||||
}
|
||||
|
||||
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
||||
if (!value) return []
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? [option.label] : [value]
|
||||
}
|
||||
|
||||
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
|
||||
const relationshipValue = ref<string[]>([])
|
||||
const relationshipInitial = ref<string[]>([])
|
||||
|
||||
const isEditingHeir = computed(() => editingHeirId.value !== null)
|
||||
|
||||
function emptyHeirForm() {
|
||||
return {
|
||||
name: '',
|
||||
ic_number: '',
|
||||
relationship: '',
|
||||
phone_number: '',
|
||||
address: '',
|
||||
is_primary: false,
|
||||
}
|
||||
}
|
||||
|
||||
const heirForm = reactive(emptyHeirForm())
|
||||
|
||||
function clearHeirFieldError(field: HeirFieldKey) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
|
||||
function clearHeirErrors() {
|
||||
for (const field of HEIR_FIELD_KEYS) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setHeirErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (HEIR_FIELD_KEYS.includes(field as HeirFieldKey) && messages[0]) {
|
||||
heirErrors[field as HeirFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(heirErrors).length > 0
|
||||
}
|
||||
|
||||
function setRelationshipValue(details: { value: string[] }) {
|
||||
relationshipValue.value = details.value
|
||||
clearHeirFieldError('relationship')
|
||||
heirForm.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncHeirSelectValues() {
|
||||
relationshipValue.value = apiValueToLabel(RELATIONSHIP_OPTIONS, heirForm.relationship)
|
||||
relationshipInitial.value = [...relationshipValue.value]
|
||||
}
|
||||
|
||||
function resetHeirForm() {
|
||||
Object.assign(heirForm, emptyHeirForm())
|
||||
editingHeirId.value = null
|
||||
clearHeirErrors()
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
function validateHeirForm(): boolean {
|
||||
clearHeirErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!heirForm.name.trim()) {
|
||||
heirErrors.name = 'Nama diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.ic_number.trim()) {
|
||||
heirErrors.ic_number = 'No. kad pengenalan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!relationshipValue.value[0] ||
|
||||
!labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0])
|
||||
) {
|
||||
heirErrors.relationship = 'Hubungan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.phone_number.trim()) {
|
||||
heirErrors.phone_number = 'No. telefon diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.address.trim()) {
|
||||
heirErrors.address = 'Alamat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildHeirPayload(): HeirPayload {
|
||||
return {
|
||||
name: heirForm.name.trim(),
|
||||
ic_number: heirForm.ic_number.trim(),
|
||||
relationship:
|
||||
labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0]) ?? heirForm.relationship,
|
||||
phone_number: heirForm.phone_number.trim(),
|
||||
address: heirForm.address.trim(),
|
||||
is_primary: heirForm.is_primary,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHeirs() {
|
||||
loadingHeirs.value = true
|
||||
|
||||
try {
|
||||
const res = await listHeirs()
|
||||
heirs.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
loadingHeirs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditHeir(heir: Heir) {
|
||||
clearHeirErrors()
|
||||
editingHeirId.value = heir.id
|
||||
heirForm.name = heir.name
|
||||
heirForm.ic_number = heir.ic_number
|
||||
heirForm.relationship = heir.relationship
|
||||
heirForm.phone_number = heir.phone_number
|
||||
heirForm.address = heir.address
|
||||
heirForm.is_primary = heir.is_primary
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveHeir() {
|
||||
if (!validateHeirForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingHeir.value = true
|
||||
const wasEditing = isEditingHeir.value
|
||||
const payload = buildHeirPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateHeir(editingHeirId.value!, payload)
|
||||
: await createHeir(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
resetHeirForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setHeirErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingHeir.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteHeir(heir: Heir) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pewaris?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingHeirId.value = heir.id
|
||||
|
||||
try {
|
||||
const res = await deleteHeir(heir.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pewaris.')
|
||||
}
|
||||
|
||||
if (editingHeirId.value === heir.id) {
|
||||
resetHeirForm()
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pewaris berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
deletingHeirId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncHeirSelectValues()
|
||||
await fetchHeirs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat pewaris anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHeirs" class="text-sm text-slate-500">
|
||||
Memuatkan pewaris...
|
||||
</div>
|
||||
|
||||
<div v-else-if="heirs.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in heirs"
|
||||
:key="heir.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
<Badge look="outline">{{ heir.relationship }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.ic_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.phone_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="startEditHeir(heir)"
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="onDeleteHeir(heir)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingHeirId === heir.id }"
|
||||
/>
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
{{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingHeir
|
||||
? 'Kemas kini maklumat pewaris yang dipilih.'
|
||||
: 'Tambah pewaris baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingHeir"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingHeir"
|
||||
@click="resetHeirForm"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingHeir">
|
||||
{{ savingHeir ? 'Menyimpan...' : isEditingHeir ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="heir-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="heir-name"
|
||||
v-model="heirForm.name"
|
||||
type="text"
|
||||
placeholder="Nama penuh"
|
||||
:aria-invalid="!!heirErrors.name"
|
||||
@input="clearHeirFieldError('name')"
|
||||
/>
|
||||
<FieldError v-if="heirErrors.name">{{ heirErrors.name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input
|
||||
id="heir-ic"
|
||||
v-model="heirForm.ic_number"
|
||||
type="text"
|
||||
placeholder="No. kad pengenalan"
|
||||
:aria-invalid="!!heirErrors.ic_number"
|
||||
@input="clearHeirFieldError('ic_number')"
|
||||
/>
|
||||
<FieldError v-if="heirErrors.ic_number">{{ heirErrors.ic_number }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`heir-relationship-${editingHeirId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="relationshipCollection"
|
||||
:default-value="relationshipInitial"
|
||||
:disabled="savingHeir"
|
||||
@value-change="setRelationshipValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in relationshipCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="heirErrors.relationship">{{ heirErrors.relationship }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-phone">No. Telefon</FieldLabel>
|
||||
<Input
|
||||
id="heir-phone"
|
||||
v-model="heirForm.phone_number"
|
||||
type="text"
|
||||
placeholder="No. telefon"
|
||||
:aria-invalid="!!heirErrors.phone_number"
|
||||
@input="clearHeirFieldError('phone_number')"
|
||||
/>
|
||||
<FieldError v-if="heirErrors.phone_number">{{ heirErrors.phone_number }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="heir-address">Alamat</FieldLabel>
|
||||
<Textarea
|
||||
id="heir-address"
|
||||
v-model="heirForm.address"
|
||||
placeholder="Alamat penuh"
|
||||
class="resize-none"
|
||||
:aria-invalid="!!heirErrors.address"
|
||||
@input="clearHeirFieldError('address')"
|
||||
/>
|
||||
<FieldError v-if="heirErrors.address">{{ heirErrors.address }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot
|
||||
:checked="heirForm.is_primary"
|
||||
:disabled="savingHeir"
|
||||
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pewaris utama</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user