DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent df0d0e743b
commit 3dc6ec41b4
145 changed files with 15028 additions and 2556 deletions
@@ -0,0 +1,328 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import {
DialogRoot,
DialogContent,
DialogTitle,
DialogDescription,
DialogCloseTrigger,
} from '@/components/ui/dialog'
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import { Lucide } from '@/components/ui/lucide'
import { Textarea } from '@/components/ui/textarea'
import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { createActivityReport, deleteActivityReport, updateActivityReport } from '../services/activity.service'
import type { ActivityReport } from '../types/activity.types'
type ReportModalMode = 'create' | 'edit'
const props = defineProps<{
activityId: string
reports: ActivityReport[]
}>()
const emit = defineEmits<{
changed: []
}>()
const { hasPermission } = usePermissions()
const canCreateReport = computed(() => hasPermission('tambah laporan aktiviti'))
const canEditReport = computed(() => hasPermission('kemaskini laporan aktiviti'))
const canDeleteReport = computed(() => hasPermission('hapus laporan aktiviti'))
const reportModalOpen = ref(false)
const reportModalMode = ref<ReportModalMode>('create')
const editingReportId = ref<string | null>(null)
const reportText = ref('')
const reportFieldError = ref<string | null>(null)
const savingReport = ref(false)
const deleteConfirmationOpen = ref(false)
const reportToDelete = ref<ActivityReport | null>(null)
const deletingReport = ref(false)
const deleteError = ref<string | null>(null)
const isEditMode = computed(() => reportModalMode.value === 'edit')
const reportModalTitle = computed(() =>
isEditMode.value ? 'Kemaskini Laporan Aktiviti' : 'Tambah Laporan Aktiviti',
)
const reportModalDescription = computed(() =>
isEditMode.value
? 'Kemaskini kandungan laporan aktiviti ini.'
: 'Rekod laporan untuk aktiviti ini. Laporan akan dikaitkan dengan akaun anda.',
)
const reportSubmitLabel = computed(() => {
if (savingReport.value) return 'Menyimpan...'
return isEditMode.value ? 'Simpan Perubahan' : 'Simpan Laporan'
})
function formatDateTime(value: string | null): string {
if (!value) return '-'
return dayjs(value).format('DD MMM YYYY, HH:mm')
}
function resetReportForm() {
reportText.value = ''
reportFieldError.value = null
editingReportId.value = null
reportModalMode.value = 'create'
}
function openCreateReportModal() {
resetReportForm()
reportModalMode.value = 'create'
reportModalOpen.value = true
}
function openEditReportModal(report: ActivityReport) {
reportFieldError.value = null
reportModalMode.value = 'edit'
editingReportId.value = report.id
reportText.value = report.report_text
reportModalOpen.value = true
}
function handleReportModalOpenChange(details: { open: boolean }) {
if (savingReport.value && !details.open) {
reportModalOpen.value = true
return
}
reportModalOpen.value = details.open
if (!details.open) {
resetReportForm()
}
}
async function handleSubmitReport() {
if (savingReport.value) return
const trimmedText = reportText.value.trim()
if (!trimmedText) {
reportFieldError.value = 'Laporan diperlukan.'
return
}
savingReport.value = true
reportFieldError.value = null
try {
if (isEditMode.value && editingReportId.value) {
await updateActivityReport(editingReportId.value, {
activity_id: props.activityId,
report_text: trimmedText,
})
} else {
await createActivityReport({
activity_id: props.activityId,
report_text: trimmedText,
})
}
reportModalOpen.value = false
resetReportForm()
emit('changed')
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors?.report_text?.[0]) {
reportFieldError.value = validationErrors.report_text[0]
} else {
reportFieldError.value = getApiErrorMessage(
err,
isEditMode.value
? 'Gagal mengemaskini laporan aktiviti.'
: 'Gagal menambah laporan aktiviti.',
)
}
} finally {
savingReport.value = false
}
}
function openDeleteConfirmation(report: ActivityReport) {
reportToDelete.value = report
deleteError.value = null
deleteConfirmationOpen.value = true
}
function handleDeleteConfirmationOpenChange(details: { open: boolean }) {
if (deletingReport.value && !details.open) {
deleteConfirmationOpen.value = true
return
}
deleteConfirmationOpen.value = details.open
if (!details.open) {
reportToDelete.value = null
deleteError.value = null
}
}
async function confirmDeleteReport() {
if (!reportToDelete.value || deletingReport.value) return
deletingReport.value = true
deleteError.value = null
try {
await deleteActivityReport(reportToDelete.value.id)
deleteConfirmationOpen.value = false
reportToDelete.value = null
emit('changed')
} catch (err) {
deleteError.value = getApiErrorMessage(err, 'Gagal memadam laporan aktiviti.')
} finally {
deletingReport.value = false
}
}
</script>
<template>
<Box class="p-5 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="font-medium">Laporan Aktiviti</div>
<p class="mt-1 text-sm opacity-70">{{ reports.length }} laporan</p>
</div>
<div class="flex items-center gap-2">
<Button
v-if="canCreateReport"
type="button"
look="outline"
size="sm"
@click="openCreateReportModal"
>
<Lucide class="mr-2 size-4" icon="Plus" />
Tambah Laporan
</Button>
<Lucide class="size-5 opacity-50" icon="FileText" />
</div>
</div>
<div v-if="!reports.length" class="mt-5 opacity-70">Tiada laporan aktiviti.</div>
<div v-else class="mt-5 space-y-4">
<div
v-for="report in reports"
:key="report.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div
class="flex flex-wrap items-start justify-between gap-3 border-b border-foreground/10 pb-3"
>
<div class="min-w-0">
<div class="text-sm font-medium">
{{ report.prepared_by_user?.name ?? 'Pengguna' }}
</div>
<div v-if="report.prepared_by_user?.email" class="mt-0.5 text-xs opacity-70">
{{ report.prepared_by_user.email }}
</div>
</div>
<div class="flex items-center gap-2">
<div class="text-xs opacity-70">
{{ formatDateTime(report.created_at) }}
</div>
<Button
v-if="canEditReport"
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
title="Kemaskini laporan"
@click="openEditReportModal(report)"
>
<Lucide class="size-4 opacity-70" icon="Pencil" />
</Button>
<Button
v-if="canDeleteReport"
type="button"
variant="ghost"
size="sm"
class="size-8 p-0"
title="Padam laporan"
@click="openDeleteConfirmation(report)"
>
<Lucide class="size-4 text-danger opacity-80" icon="Trash2" />
</Button>
</div>
</div>
<p class="mt-3 whitespace-pre-line text-sm leading-relaxed">
{{ report.report_text }}
</p>
</div>
</div>
</Box>
<DialogRoot :open="reportModalOpen" @openChange="handleReportModalOpenChange">
<DialogContent class="max-w-lg">
<DialogTitle>{{ reportModalTitle }}</DialogTitle>
<DialogDescription>{{ reportModalDescription }}</DialogDescription>
<form class="mt-4 space-y-4" @submit.prevent="handleSubmitReport">
<Field>
<FieldLabel for="activity-report-text">Laporan</FieldLabel>
<Textarea
id="activity-report-text"
v-model="reportText"
rows="6"
placeholder="Tulis laporan aktiviti..."
:disabled="savingReport"
required
/>
<FieldError v-if="reportFieldError">{{ reportFieldError }}</FieldError>
</Field>
<div class="flex justify-end gap-2 pt-2">
<DialogCloseTrigger look="outline" variant="secondary" :disabled="savingReport">
Batal
</DialogCloseTrigger>
<Button type="submit" variant="primary" :disabled="savingReport">
{{ reportSubmitLabel }}
</Button>
</div>
</form>
</DialogContent>
</DialogRoot>
<DialogRoot :open="deleteConfirmationOpen" @openChange="handleDeleteConfirmationOpenChange">
<DialogContent>
<div class="p-5 text-center">
<Lucide class="text-danger mx-auto mt-3 size-16 stroke-1" icon="CircleX" />
<div class="mt-5 text-2xl font-medium">Padam Laporan Aktiviti?</div>
<div class="mt-2 opacity-70">
Adakah anda benar-benar mahu memadam laporan ini?
<br />
Proses ini tidak boleh dibatalkan.
</div>
<div v-if="deleteError" class="mt-4 text-sm text-danger">
{{ deleteError }}
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-24" :disabled="deletingReport">
Batal
</DialogCloseTrigger>
<Button
class="w-24"
type="button"
variant="danger"
look="outline"
:disabled="deletingReport"
@click="confirmDeleteReport"
>
{{ deletingReport ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</template>