DONE: add bos signature on letter, remove unused files or templates files, update logo to mykopkb, remove theme switcher use gradient colour, add external system module on frontend
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listActivityLogs } from '../services/activity-log.service'
|
||||
import type { ActivityLogItem } from '../types/activity-log.types'
|
||||
|
||||
export function useActivityLogList() {
|
||||
const activityLogs = ref<ActivityLogItem[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
async function fetchActivityLogs(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await listActivityLogs({
|
||||
page: requestPage,
|
||||
per_page: itemsPerPage.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
|
||||
activityLogs.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan log aktiviti.')
|
||||
activityLogs.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
fetchActivityLogs(1)
|
||||
}, 400)
|
||||
|
||||
watch(search, () => {
|
||||
debouncedSearch()
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchActivityLogs(nextPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(itemsPerPage, (nextValue, previousValue) => {
|
||||
if (nextValue !== previousValue) {
|
||||
fetchActivityLogs(1)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivityLogs(1)
|
||||
})
|
||||
|
||||
return {
|
||||
activityLogs,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
fetchActivityLogs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activityLogLayoutRoutes } from './routes'
|
||||
export { activityLogMenu } from './menu'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const activityLogMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ScrollText',
|
||||
route_name: 'list-activity-logs',
|
||||
title: 'Log Aktiviti',
|
||||
permission: 'lihat log aktiviti',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { Search } from '@lucide/vue'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useActivityLogList } from '../composables/useActivityLogList'
|
||||
import { formatModelType } from '../utils/activity-log.utils'
|
||||
|
||||
const {
|
||||
activityLogs,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
page,
|
||||
itemsPerPage,
|
||||
pagination,
|
||||
} = useActivityLogList()
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatCauserName(item: { causer?: { name?: string } | null }): string {
|
||||
return item.causer?.name ?? '-'
|
||||
}
|
||||
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{
|
||||
title: 'Tarikh',
|
||||
key: 'created_at',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatDateTime(item.created_at),
|
||||
},
|
||||
{
|
||||
title: 'Pengguna',
|
||||
key: 'causer.name',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatCauserName(item),
|
||||
},
|
||||
{
|
||||
title: 'Emel',
|
||||
key: 'causer.email',
|
||||
sortable: false,
|
||||
exportValue: (item) => item.causer?.email ?? '-',
|
||||
},
|
||||
{ title: 'Keterangan', key: 'description', sortable: false },
|
||||
{
|
||||
title: 'Subjek',
|
||||
key: 'subject_type',
|
||||
sortable: false,
|
||||
exportValue: (item) => formatModelType(item.subject_type),
|
||||
},
|
||||
{ title: 'Peristiwa', key: 'event', sortable: false },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Log Aktiviti</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Semak rekod aktiviti pengguna dalam sistem.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable :headers="headers" :items="activityLogs" :loading="loading" :pagination="pagination" show-pagination
|
||||
exportable export-file-name="activity-logs" v-model:page="page" v-model:items-per-page="itemsPerPage">
|
||||
<template #toolbar>
|
||||
<div class="relative w-full max-w-md flex-1">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
||||
aria-hidden="true" />
|
||||
<Input v-model="search" type="search" placeholder="Cari keterangan, subjek, peristiwa, pengguna..."
|
||||
class="w-full pl-9" aria-label="Cari log aktiviti" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.created_at="{ item }">
|
||||
{{ formatDateTime(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.causer.name="{ item }">
|
||||
{{ formatCauserName(item) }}
|
||||
</template>
|
||||
|
||||
<template #item.causer.email="{ item }">
|
||||
<span class="lowercase">{{ item.causer?.email ?? '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.subject_type="{ item }">
|
||||
{{ formatModelType(item.subject_type) }}
|
||||
</template>
|
||||
|
||||
<template #item.event="{ item }">
|
||||
{{ item.event ?? '-' }}
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const activityLogLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'activity-logs',
|
||||
name: 'list-activity-logs',
|
||||
component: () => import('./pages/ActivityLogList.vue'),
|
||||
meta: {
|
||||
title: 'Log Aktiviti',
|
||||
module: 'activity-log',
|
||||
permission: 'lihat log aktiviti',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { PaginatedApiResponse } from '@/core/types/api'
|
||||
import type { ActivityLogItem, ListActivityLogsParams } from '../types/activity-log.types'
|
||||
|
||||
export async function listActivityLogs(
|
||||
params: ListActivityLogsParams,
|
||||
): Promise<PaginatedApiResponse<ActivityLogItem>> {
|
||||
const { data } = await api.get<PaginatedApiResponse<ActivityLogItem>>('/v1/activitylogs', {
|
||||
params,
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to load activity logs')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type ActivityLogCauser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type ActivityLogItem = {
|
||||
id: number
|
||||
log_name: string | null
|
||||
description: string
|
||||
subject_type: string | null
|
||||
subject_id: string | null
|
||||
event: string | null
|
||||
causer_type: string | null
|
||||
causer_id: string | null
|
||||
properties: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
causer: ActivityLogCauser | null
|
||||
}
|
||||
|
||||
export type ListActivityLogsParams = {
|
||||
page?: number
|
||||
per_page?: number
|
||||
search?: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatModelType(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
|
||||
const segments = value.split('\\')
|
||||
return segments[segments.length - 1] || value
|
||||
}
|
||||
@@ -11,14 +11,14 @@ const sections = [
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: `
|
||||
Terma dan Syarat ini mengawal penggunaan platform, laman web, dan perkhidmatan yang disediakan oleh Kementerian Pertahanan Malaysia (MINDEF). Dengan mengakses atau menggunakan perkhidmatan kami, anda dianggap telah membaca, memahami, dan bersetuju untuk terikat dengan terma ini. Jika anda tidak bersetuju dengan mana-mana bahagian terma ini, sila hentikan penggunaan perkhidmatan kami.
|
||||
Terma dan Syarat ini mengawal penggunaan platform, laman web, dan perkhidmatan yang disediakan oleh Koperasi Permodalan Kelantan Berhad (KOPKB). Dengan mengakses atau menggunakan perkhidmatan kami, anda dianggap telah membaca, memahami, dan bersetuju untuk terikat dengan terma ini. Jika anda tidak bersetuju dengan mana-mana bahagian terma ini, sila hentikan penggunaan perkhidmatan kami.
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'akaun',
|
||||
title: '2. Akaun & Kelayakan',
|
||||
placeholder: `
|
||||
Anda mesti berumur sekurang-kurangnya 18 tahun dan merupakan tentera. Anda bertanggungjawab memastikan maklumat akaun sentiasa tepat dan terkini. Anda juga bertanggungjawab menjaga kerahsiaan kata laluan serta segala aktiviti yang berlaku di bawah akaun anda.
|
||||
Anda mesti berumur sekurang-kurangnya 18 tahun dan merupakan ahli KOPKB. Anda bertanggungjawab memastikan maklumat akaun sentiasa tepat dan terkini. Anda juga bertanggungjawab menjaga kerahsiaan kata laluan serta segala aktiviti yang berlaku di bawah akaun anda.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -39,7 +39,7 @@ Sesetengah perkhidmatan mungkin tertakluk kepada bayaran yang dinyatakan semasa
|
||||
id: 'kandungan',
|
||||
title: '5. Kandungan & Hak Milik',
|
||||
placeholder: `
|
||||
Semua kandungan, reka bentuk, logo, teks, grafik, dan bahan lain yang terdapat pada platform ini adalah hak milik Kementerian Pertahanan Malaysia (MINDEF) atau pemberi lesennya. Anda diberikan lesen terhad untuk menggunakan kandungan tersebut bagi tujuan penggunaan peribadi dan bukan komersial sahaja. Sebarang penyalinan, pengubahsuaian, atau pengedaran tanpa kebenaran bertulis adalah dilarang.
|
||||
Semua kandungan, reka bentuk, logo, teks, grafik, dan bahan lain yang terdapat pada platform ini adalah hak milik Koperasi Permodalan Kelantan Berhad (KOPKB) atau pemberi lesennya. Anda diberikan lesen terhad untuk menggunakan kandungan tersebut bagi tujuan penggunaan peribadi dan bukan komersial sahaja. Sebarang penyalinan, pengubahsuaian, atau pengedaran tanpa kebenaran bertulis adalah dilarang.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -69,8 +69,8 @@ Kami boleh mengemas kini atau meminda Terma dan Syarat ini dari semasa ke semasa
|
||||
placeholder: `
|
||||
Sekiranya anda mempunyai sebarang pertanyaan berkaitan Terma dan Syarat ini, sila hubungi:
|
||||
|
||||
Nama: KAPT MOHAMMAD EFANDY BIN JAFFARI
|
||||
E-mel: effandy.jaffari@army.mil.my
|
||||
Nama: Koperasi Permodalan Kelantan Berhad (KOPKB)
|
||||
E-mel: admin@koppkb.com
|
||||
`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useExternalSystemList } from './useExternalSystemList'
|
||||
import {
|
||||
getExternalSystemStatus,
|
||||
isExternalSystemAccessible,
|
||||
} from '../utils/external-system.utils'
|
||||
|
||||
export function useExternalSystemDetail() {
|
||||
const route = useRoute()
|
||||
const { getSystemById } = useExternalSystemList()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const systemId = computed(() => String(route.params.id ?? ''))
|
||||
|
||||
const system = computed(() => getSystemById(systemId.value) ?? null)
|
||||
|
||||
const status = computed(() => (system.value ? getExternalSystemStatus(system.value) : null))
|
||||
|
||||
const isAccessible = computed(() =>
|
||||
system.value ? isExternalSystemAccessible(system.value) : false,
|
||||
)
|
||||
|
||||
watch(
|
||||
systemId,
|
||||
() => {
|
||||
error.value = system.value ? null : 'Sistem luaran tidak dijumpai.'
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
system,
|
||||
loading,
|
||||
error,
|
||||
status,
|
||||
isAccessible,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { dummyExternalSystems } from '../data/dummy-external-systems'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
getExternalSystemStatus,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export function useExternalSystemList() {
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const systems = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
if (!query) {
|
||||
return dummyExternalSystems
|
||||
}
|
||||
|
||||
return dummyExternalSystems.filter((system) => {
|
||||
const haystack = [
|
||||
system.name,
|
||||
system.code,
|
||||
system.description,
|
||||
externalSystemStatusLabel(getExternalSystemStatus(system)),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const availableCount = computed(
|
||||
() => systems.value.filter((system) => getExternalSystemStatus(system) === 'available').length,
|
||||
)
|
||||
|
||||
function getSystemById(id: string): ExternalSystem | undefined {
|
||||
return dummyExternalSystems.find((system) => system.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
systems,
|
||||
search,
|
||||
loading,
|
||||
availableCount,
|
||||
getSystemById,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
export const dummyExternalSystems: ExternalSystem[] = [
|
||||
{
|
||||
id: 'ext-001',
|
||||
code: 'KOPKB-PORTAL',
|
||||
name: 'Portal Ahli KOPKB',
|
||||
description:
|
||||
'Sistem utama keahlian Koperasi Permodalan Kelantan Berhad untuk semakan dividen, penyata dan maklumat ahli.',
|
||||
url: 'https://anggota.koppkb.com',
|
||||
icon: 'Users',
|
||||
is_active: true,
|
||||
starts_at: '2026-01-01T00:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'sokongan@koppkb.com',
|
||||
notes: 'Log masuk menggunakan e-mel berdaftar ahli KOPKB.',
|
||||
created_at: '2026-01-15T09:00:00+08:00',
|
||||
updated_at: '2026-06-01T14:30:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 'ext-002',
|
||||
code: 'AGM-VOTE',
|
||||
name: 'Sistem Pengundian AGM',
|
||||
description:
|
||||
'Platform pengundian dalam talian untuk Mesyuarat Agung Tahunan. Hanya tersedia semasa tempoh pengundian.',
|
||||
url: 'https://e-vote.erahn.com.my/login',
|
||||
icon: 'Vote',
|
||||
is_active: true,
|
||||
starts_at: '2026-05-01T08:00:00+08:00',
|
||||
ends_at: null,
|
||||
opens_in_new_tab: true,
|
||||
contact_email: 'agm@koppkb.com',
|
||||
notes: 'Sila lengkapkan profil sebelum mengundi.',
|
||||
created_at: '2026-05-20T10:00:00+08:00',
|
||||
updated_at: '2026-06-28T11:15:00+08:00',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
export { externalSystemLayoutRoutes } from './routes'
|
||||
export { externalSystemMenu } from './menu'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Menu } from '@/core/types/menu'
|
||||
|
||||
export const externalSystemMenu: Menu[] = [
|
||||
{
|
||||
icon: 'ExternalLink',
|
||||
route_name: 'list-external-systems',
|
||||
title: 'Sistem Luaran',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemDetail } from '../composables/useExternalSystemDetail'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
|
||||
const router = useRouter()
|
||||
const { system, error, status, isAccessible } = useExternalSystemDetail()
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'list-external-systems' })
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
if (!system.value) return
|
||||
openExternalSystem(system.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5 flex flex-wrap items-center gap-3">
|
||||
<Button variant="ghost" look="outline" @click="goBack">
|
||||
<Lucide icon="ArrowLeft" class="size-4" />
|
||||
Kembali
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="error" class="mb-6" variant="danger">
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</AlertRoot>
|
||||
|
||||
<template v-else-if="system && status">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 items-start gap-4">
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary"
|
||||
>
|
||||
<Lucide :icon="system.icon" class="size-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm opacity-70">Sistem Luaran</div>
|
||||
<h2 class="text-xl font-semibold">{{ system.name }}</h2>
|
||||
<div class="mt-1 text-sm font-medium text-primary/80">{{ system.code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge look="outline" :variant="externalSystemStatusVariant(status)">
|
||||
{{ externalSystemStatusLabel(status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 max-w-3xl text-sm leading-relaxed opacity-80">
|
||||
{{ system.description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">URL:</span>
|
||||
<div class="mt-0.5 break-all font-medium">{{ system.url }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Mula:</span>
|
||||
<div class="mt-0.5 font-medium">{{ formatExternalSystemDateTime(system.starts_at) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Tamat:</span>
|
||||
<div class="mt-0.5 font-medium">{{ formatExternalSystemDateTime(system.ends_at) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">E-mel Sokongan:</span>
|
||||
<div class="mt-0.5 font-medium">{{ system.contact_email ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="system.notes" class="mt-6 border-t border-foreground/10 pt-5">
|
||||
<div class="text-sm font-medium opacity-70">Nota</div>
|
||||
<p class="mt-2 text-sm leading-relaxed opacity-80">{{ system.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3 border-t border-foreground/10 pt-5">
|
||||
<Button
|
||||
look="outline"
|
||||
variant="primary"
|
||||
:disabled="!isAccessible"
|
||||
@click="handleOpen"
|
||||
>
|
||||
Buka Sistem
|
||||
<Lucide icon="ExternalLink" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="!isAccessible" class="mt-4" look="outline" variant="pending">
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Clock" />
|
||||
<AlertTitle>Sistem Tidak Tersedia</AlertTitle>
|
||||
<AlertDescription>
|
||||
Pautan ini hanya boleh dibuka semasa tempoh acara yang ditetapkan dan sistem berstatus
|
||||
aktif.
|
||||
</AlertDescription>
|
||||
</AlertRoot>
|
||||
</Box>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { useExternalSystemList } from '../composables/useExternalSystemList'
|
||||
import {
|
||||
externalSystemStatusLabel,
|
||||
externalSystemStatusVariant,
|
||||
formatExternalSystemDateTime,
|
||||
getExternalSystemStatus,
|
||||
openExternalSystem,
|
||||
} from '../utils/external-system.utils'
|
||||
import type { ExternalSystem } from '../types/external-system.types'
|
||||
|
||||
const router = useRouter()
|
||||
const { systems, search, loading, availableCount } = useExternalSystemList()
|
||||
|
||||
function goToDetail(system: ExternalSystem) {
|
||||
router.push({ name: 'view-external-system', params: { id: system.id } })
|
||||
}
|
||||
|
||||
function handleOpen(system: ExternalSystem) {
|
||||
openExternalSystem(system)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Sistem Luaran</h2>
|
||||
</div>
|
||||
<Badge look="outline" variant="primary">
|
||||
{{ availableCount }} tersedia
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<div class="col-span-12 mt-2 flex flex-wrap items-center sm:flex-nowrap">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="relative w-56">
|
||||
<Input v-model="search" class="w-56 pr-10" type="search" placeholder="Cari sistem..." :disabled="loading" />
|
||||
<Lucide class="absolute inset-y-0 right-0 my-auto mr-3 h-4 w-4" icon="Search" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="systems.length">
|
||||
<Box v-for="system in systems" :key="system.id"
|
||||
class="col-span-12 flex h-full flex-col p-5 md:col-span-6 xl:col-span-4">
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Lucide :icon="system.icon" class="size-5" />
|
||||
</div>
|
||||
<Badge look="outline" :variant="externalSystemStatusVariant(getExternalSystemStatus(system))">
|
||||
{{ externalSystemStatusLabel(getExternalSystemStatus(system)) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="text-base font-medium">{{ system.name }}</div>
|
||||
<div class="mt-1 text-xs font-medium uppercase tracking-wide text-primary/80">
|
||||
{{ system.code }}
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-sm leading-relaxed opacity-70">
|
||||
{{ system.description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-1 text-xs opacity-70">
|
||||
<div>
|
||||
<span class="font-medium">Mula:</span>
|
||||
{{ formatExternalSystemDateTime(system.starts_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Tamat:</span>
|
||||
{{ formatExternalSystemDateTime(system.ends_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-2 sm:flex-row">
|
||||
<Button class="w-full sm:flex-1" look="outline" variant="primary"
|
||||
:disabled="getExternalSystemStatus(system) !== 'available'" @click="handleOpen(system)">
|
||||
Buka Sistem
|
||||
<Lucide icon="ExternalLink" class="size-4" />
|
||||
</Button>
|
||||
<Button class="w-full sm:flex-1" variant="ghost" @click="goToDetail(system)">
|
||||
Butiran
|
||||
<Lucide icon="ArrowRight" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</template>
|
||||
|
||||
<Box v-else class="col-span-12 p-8 text-center">
|
||||
<Lucide icon="SearchX" class="mx-auto size-8 opacity-40" />
|
||||
<div class="mt-3 text-base font-medium">Tiada sistem dijumpai</div>
|
||||
<p class="mt-1 text-sm opacity-70">Cuba istilah carian yang berbeza.</p>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const externalSystemLayoutRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'external-systems',
|
||||
name: 'list-external-systems',
|
||||
component: () => import('./pages/ExternalSystemList.vue'),
|
||||
meta: {
|
||||
title: 'Senarai Sistem Luaran',
|
||||
module: 'external-system',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'external-systems/:id',
|
||||
name: 'view-external-system',
|
||||
component: () => import('./pages/ExternalSystemDetail.vue'),
|
||||
meta: {
|
||||
title: 'Butiran Sistem Luaran',
|
||||
module: 'external-system',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Icon } from '@/components/ui/lucide'
|
||||
|
||||
export type ExternalSystemStatus = 'available' | 'upcoming' | 'ended' | 'inactive'
|
||||
|
||||
export type ExternalSystem = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
icon: Icon
|
||||
is_active: boolean
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
opens_in_new_tab: boolean
|
||||
contact_email: string | null
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import dayjs from 'dayjs'
|
||||
import type { BadgeVariants } from '@/components/ui/styles/badge.styles'
|
||||
import type { ExternalSystem, ExternalSystemStatus } from '../types/external-system.types'
|
||||
|
||||
export function getExternalSystemStatus(
|
||||
system: ExternalSystem,
|
||||
now = dayjs(),
|
||||
): ExternalSystemStatus {
|
||||
if (!system.is_active) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
const startsAt = system.starts_at ? dayjs(system.starts_at) : null
|
||||
const endsAt = system.ends_at ? dayjs(system.ends_at) : null
|
||||
|
||||
if (startsAt?.isAfter(now)) {
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
if (endsAt?.isBefore(now)) {
|
||||
return 'ended'
|
||||
}
|
||||
|
||||
return 'available'
|
||||
}
|
||||
|
||||
export function isExternalSystemAccessible(system: ExternalSystem, now = dayjs()): boolean {
|
||||
return getExternalSystemStatus(system, now) === 'available'
|
||||
}
|
||||
|
||||
export function externalSystemStatusLabel(status: ExternalSystemStatus): string {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'Tersedia'
|
||||
case 'upcoming':
|
||||
return 'Akan Datang'
|
||||
case 'ended':
|
||||
return 'Tamat'
|
||||
case 'inactive':
|
||||
return 'Tidak Aktif'
|
||||
}
|
||||
}
|
||||
|
||||
export function externalSystemStatusVariant(
|
||||
status: ExternalSystemStatus,
|
||||
): NonNullable<BadgeVariants['variant']> {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success'
|
||||
case 'upcoming':
|
||||
return 'pending'
|
||||
case 'ended':
|
||||
return 'ghost'
|
||||
case 'inactive':
|
||||
return 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatExternalSystemDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
export function openExternalSystem(system: ExternalSystem) {
|
||||
if (!isExternalSystemAccessible(system)) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
system.url,
|
||||
system.opens_in_new_tab ? '_blank' : '_self',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
@@ -223,7 +223,7 @@ onMounted(async () => {
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ displayValue(companyName) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Syarikat</div>
|
||||
<div class="text-xs opacity-70">Unit</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
|
||||
@@ -113,7 +113,7 @@ onMounted(() => {
|
||||
<div class="flex items-center gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<Lucide class="size-5 text-primary" icon="Briefcase" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Syarikat</div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Unit</div>
|
||||
<div class="truncate text-base font-medium text-slate-900">
|
||||
{{ displayValue(member.company_name) }}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user