DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { getUser, updateUser } from '../services/user.service'
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
]
|
||||
|
||||
const GENDER_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lelaki', value: 'Lelaki' },
|
||||
{ label: 'Perempuan', value: 'Perempuan' },
|
||||
]
|
||||
|
||||
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
||||
{ label: 'Berkahwin', value: 'Berkahwin' },
|
||||
{ label: 'Bercerai', value: 'Bercerai' },
|
||||
{ label: 'Balu', value: 'Balu' },
|
||||
{ label: 'Duda', value: 'Duda' },
|
||||
]
|
||||
|
||||
const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Anggota', value: 'Anggota' },
|
||||
{ label: 'Pesara', value: 'Pesara' },
|
||||
]
|
||||
|
||||
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] : []
|
||||
}
|
||||
|
||||
const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||
|
||||
const statusValue = ref<string[]>([])
|
||||
const genderValue = ref<string[]>([])
|
||||
const marriageStatusValue = ref<string[]>([])
|
||||
const memberTypeValue = ref<string[]>([])
|
||||
|
||||
const statusInitial = ref<string[]>([])
|
||||
const genderInitial = ref<string[]>([])
|
||||
const marriageStatusInitial = ref<string[]>([])
|
||||
const memberTypeInitial = ref<string[]>([])
|
||||
|
||||
function setStatusValue(details: { value: string[] }) {
|
||||
statusValue.value = details.value
|
||||
}
|
||||
|
||||
function setGenderValue(details: { value: string[] }) {
|
||||
genderValue.value = details.value
|
||||
}
|
||||
|
||||
function setMarriageStatusValue(details: { value: string[] }) {
|
||||
marriageStatusValue.value = details.value
|
||||
}
|
||||
|
||||
function setMemberTypeValue(details: { value: string[] }) {
|
||||
memberTypeValue.value = details.value
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const userId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
|
||||
const email = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
||||
email.value = user.email ?? ''
|
||||
form.name = user.name ?? ''
|
||||
form.ic_number = user.ic_number ?? ''
|
||||
form.position = user.position ?? ''
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
form.member_number = user.member_number != null ? String(user.member_number) : ''
|
||||
form.join_date = toDateInputValue(user.join_date)
|
||||
form.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
|
||||
genderValue.value = apiValueToLabel(GENDER_OPTIONS, user.gender)
|
||||
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, user.marriage_status)
|
||||
memberTypeValue.value = apiValueToLabel(MEMBER_TYPE_OPTIONS, user.member_type)
|
||||
statusInitial.value = [...statusValue.value]
|
||||
genderInitial.value = [...genderValue.value]
|
||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||
memberTypeInitial.value = [...memberTypeValue.value]
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await getUser(userId.value)
|
||||
syncFormFromUser(response.data)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan maklumat pengguna.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await updateUser(userId.value, {
|
||||
name: form.name.trim(),
|
||||
ic_number: form.ic_number.trim(),
|
||||
position: form.position.trim(),
|
||||
phone_number: form.phone_number.trim() || null,
|
||||
status: labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
|
||||
gender: labelToApiValue(GENDER_OPTIONS, genderValue.value[0]),
|
||||
marriage_status: labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0]),
|
||||
member_number: form.member_number ? Number(form.member_number) : null,
|
||||
member_type: labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0]),
|
||||
join_date: form.join_date || null,
|
||||
birth_date: form.birth_date || null,
|
||||
birth_place: form.birth_place.trim() || null,
|
||||
})
|
||||
|
||||
successMessage.value = 'Pengguna berjaya dikemaskini.'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'list-users' })
|
||||
}, 300)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal mengemaskini pengguna.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formDisabled = computed(() => loading.value || saving.value)
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="mr-auto text-lg font-medium">Kemaskini Pengguna</h2>
|
||||
<Button look="outline" variant="secondary" type="button" :disabled="saving"
|
||||
@click="router.push({ name: 'list-users' })">
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<Box>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-name">Nama</FieldLabel>
|
||||
<Input id="user-name" v-model="form.name" class="w-full" type="text" placeholder="Nama penuh"
|
||||
:disabled="formDisabled" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-email">Emel</FieldLabel>
|
||||
<Input id="user-email" v-model="email" class="w-full" type="email" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-ic">Nombor Kad Pengenalan</FieldLabel>
|
||||
<Input id="user-ic" v-model="form.ic_number" class="w-full" type="text" placeholder="Nombor kad pengenalan"
|
||||
:disabled="formDisabled" required />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-phone">Nombor Telefon</FieldLabel>
|
||||
<Input id="user-phone" v-model="form.phone_number" class="w-full" type="text" placeholder="Nombor telefon"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-position">Jawatan</FieldLabel>
|
||||
<Input id="user-position" v-model="form.position" class="w-full" type="text" placeholder="Jawatan"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Pengguna</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="statusCollection" :default-value="statusInitial"
|
||||
:disabled="formDisabled" @value-change="setStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in statusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="genderCollection" :default-value="genderInitial"
|
||||
:disabled="formDisabled" @value-change="setGenderValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jantina" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="marriageStatusCollection"
|
||||
:default-value="marriageStatusInitial" :disabled="formDisabled" @value-change="setMarriageStatusValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih status perkahwinan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-member-number">Nombor Anggota</FieldLabel>
|
||||
<Input id="user-member-number" v-model="form.member_number" class="w-full" type="number" min="0"
|
||||
placeholder="Nombor anggota" :disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Jenis Anggota</FieldLabel>
|
||||
<SelectRoot v-if="!loading" class="w-full" :collection="memberTypeCollection"
|
||||
:default-value="memberTypeInitial" :disabled="formDisabled" @value-change="setMemberTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder="Pilih jenis anggota" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Anggota</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in memberTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="user-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-place">Tempat Lahir</FieldLabel>
|
||||
<Input id="user-birth-place" v-model="form.birth_place" class="w-full" type="text" placeholder="Tempat lahir"
|
||||
:disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2">
|
||||
<Button type="submit" variant="primary" look="outline" :disabled="formDisabled">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user