From c95da960171a77b81b906afe507068e79f6c3bea Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Thu, 18 Dec 2025 15:11:41 +0700 Subject: [PATCH] update user login baru dan hakakses --- PLACEHOLDER_API_README.md | 70 ++++ components/HakAkses/EditHakAkses.vue | 390 ++++++++++++++--- components/layout/ProfileMenu.vue | 30 +- components/layout/ProfilePopup.vue | 46 +- layouts/default.vue | 47 ++- middleware/permissions.ts | 239 +++++++++++ nuxt.config.ts | 5 +- pages/CheckInPasien/checkIn.vue | 513 ++++++++++++++++++++--- pages/MonitoringPasien/pasien/[id].vue | 8 +- pages/Profile/Profil.vue | 105 ----- pages/Setting/HakAkses.vue | 187 ++++++++- pages/Setting/UserLogin.vue | 350 +++++++++++++--- server/api/auth/keycloak-callback.get.ts | 61 ++- server/api/permission.get.ts | 177 +++++++- server/api/users/[id].patch.ts | 133 ++++++ server/api/users/create.post.ts | 47 ++- server/api/users/list.get.ts | 61 ++- server/api/users/sync-all.post.ts | 401 ++++++++++++++++++ server/api/users/sync.post.ts | 7 + server/utils/userSync.ts | 91 ++-- 20 files changed, 2564 insertions(+), 404 deletions(-) create mode 100644 PLACEHOLDER_API_README.md create mode 100644 middleware/permissions.ts create mode 100644 server/api/users/sync-all.post.ts diff --git a/PLACEHOLDER_API_README.md b/PLACEHOLDER_API_README.md new file mode 100644 index 0000000..dfa439a --- /dev/null +++ b/PLACEHOLDER_API_README.md @@ -0,0 +1,70 @@ +# Placeholder API Permission + +## Deskripsi +Placeholder API untuk permission digunakan untuk testing dan development ketika backend API tidak tersedia. + +## Cara Menggunakan + +### 1. Menggunakan Placeholder API secara Default +Placeholder API akan otomatis digunakan sebagai fallback jika backend API (`http://10.10.150.131:8089/api/v1/permission`) tidak dapat diakses. + +### 2. Memaksa Menggunakan Placeholder API +Tambahkan query parameter `usePlaceholder=true` pada request: +``` +GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=true +``` + +### 3. Menonaktifkan Placeholder API +Tambahkan query parameter `usePlaceholder=false` pada request: +``` +GET /api/permission?roles=superadmin&groups=STIM&usePlaceholder=false +``` + +## Data Placeholder yang Tersedia + +### Role: superadmin, Group: STIM +Data placeholder mengembalikan 5 permission items: +- Halaman Utama (read: true, active: true) +- Pengaturan (read: true, active: true) +- Halaman (read: true, active: true, disable: true) +- Dashboard (read: true, active: true, disable: true) + +## Mapping Pagename ke Menu Sidebar + +Sistem akan otomatis memetakan pagename dari API ke nama menu di sidebar: +- "Halaman Utama" → "Dashboard" +- "Pengaturan" → "Master Data" +- "Halaman" → "Master Data" +- "Dashboard" → "Dashboard" + +## Testing dengan User bayurssa + +Untuk testing dengan user email "bayurssa": +1. Pastikan user memiliki role "superadmin" dan group "STIM" di Keycloak +2. Login dengan email "bayurssa" dan password "12345" +3. Sistem akan otomatis menggunakan placeholder API jika backend tidak tersedia +4. Sidebar akan terfilter berdasarkan permissions dari placeholder API + +## Mapping Role dan Group + +Sistem secara otomatis melakukan normalisasi untuk role dan group: + +### Normalisasi Role +- `default-roles-sandbox` → `superadmin` +- Role lain akan digunakan apa adanya (lowercase) + +### Normalisasi Group +- `Instalasi STIM` → `STIM` +- Group yang mengandung "Instalasi" akan diekstrak untuk mengambil bagian "STIM" +- Group lain akan digunakan apa adanya (uppercase) + +### Contoh Mapping +- Role: `default-roles-sandbox` + Group: `Instalasi STIM` → API akan menggunakan `roles=superadmin&groups=STIM` +- Role: `default-roles-sandbox` + Group: `STIM` → API akan menggunakan `roles=superadmin&groups=STIM` + +## Catatan +- Placeholder API hanya tersedia untuk kombinasi role dan group yang sudah didefinisikan +- Sistem akan otomatis melakukan normalisasi role dan group sebelum memanggil API +- Jika role/group tidak ditemukan di placeholder, sistem akan mencoba menggunakan backend API +- Jika backend API juga gagal, sistem akan mengembalikan data kosong + diff --git a/components/HakAkses/EditHakAkses.vue b/components/HakAkses/EditHakAkses.vue index ed16bda..df08103 100644 --- a/components/HakAkses/EditHakAkses.vue +++ b/components/HakAkses/EditHakAkses.vue @@ -1,71 +1,161 @@ \ No newline at end of file diff --git a/components/layout/ProfileMenu.vue b/components/layout/ProfileMenu.vue index 9bf9cf7..53a964d 100644 --- a/components/layout/ProfileMenu.vue +++ b/components/layout/ProfileMenu.vue @@ -58,22 +58,22 @@ + + Profil + + - Setting - - - - Bantuan + Pengaturan @@ -101,6 +101,7 @@ diff --git a/components/layout/ProfilePopup.vue b/components/layout/ProfilePopup.vue index 8727b93..2e32575 100644 --- a/components/layout/ProfilePopup.vue +++ b/components/layout/ProfilePopup.vue @@ -108,7 +108,7 @@
Pengaturan
- - - - - - -
- - - - Notifikasi - - - - - - - - Bantuan & Dukungan - - +
--> @@ -217,12 +183,6 @@ const handleAction = (action) => { case 'profile': navigateTo('/Profile/Profil') break; - case 'notifications': - navigateTo('/notifications') - break; - case 'help': - navigateTo('/help') - break; case 'darkMode': darkMode.value = !darkMode.value; return; @@ -350,7 +310,7 @@ const handleAction = (action) => { .menu-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 12px; } diff --git a/layouts/default.vue b/layouts/default.vue index f61465c..c7f456e 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -22,7 +22,7 @@ import { useNavItemsStore } from '~/stores/navItems1'; import { useAuth } from "~/composables/useAuth"; definePageMeta({ - middleware: 'auth' + middleware: ['auth', 'permissions'] }) // State for controlling the sidebar @@ -98,7 +98,8 @@ const fetchCurrentUserData = async () => { } }; -// Fetch permissions from backend API +// Fetch permissions from backend API (only for nav filtering, not for saving) +// Saving is now handled by permissions middleware const fetchPermissionsFromAPI = async () => { const { roles, groups } = await fetchCurrentUserData(); @@ -125,6 +126,7 @@ const fetchPermissionsFromAPI = async () => { if (response && response.data && Array.isArray(response.data)) { apiPermissions.value = response.data; + // Note: Auto-save to allHakAksesData is now handled by permissions middleware } } catch (error) { console.error('Error fetching permissions from API:', error); @@ -181,20 +183,51 @@ const filteredNavItems = computed(() => { apiPermissions.value.map((perm) => [perm.pagename.toLowerCase(), perm]) ); + // Mapping untuk pagename dari API ke nama menu di sidebar + const pagenameToMenuMapping: Record = { + 'halaman utama': ['dashboard', 'halaman utama'], + 'pengaturan': ['master data'], + 'halaman': ['master data'], + 'dashboard': ['dashboard'], + }; + const applyFilter = (items: NavItem[]): NavItem[] => { return items .map((item) => { // Try to match by pagename or menu name - const perm = permissionMap.get(item.name.toLowerCase()) || - Array.from(permissionMap.values()).find(p => - p.pagename?.toLowerCase().includes(item.name.toLowerCase()) || - item.name.toLowerCase().includes(p.pagename?.toLowerCase()) - ); + let perm = permissionMap.get(item.name.toLowerCase()); + + // If no direct match, try fuzzy matching + if (!perm) { + perm = Array.from(permissionMap.values()).find(p => { + const pagenameLower = p.pagename?.toLowerCase() || ''; + const menuNameLower = item.name.toLowerCase(); + + // Direct match + if (pagenameLower === menuNameLower) return true; + + // Contains match + if (pagenameLower.includes(menuNameLower) || menuNameLower.includes(pagenameLower)) { + return true; + } + + // Check mapping + const mappedMenus = pagenameToMenuMapping[pagenameLower]; + if (mappedMenus && mappedMenus.some(m => m === menuNameLower)) { + return true; + } + + return false; + }); + } const filteredChildren = item.children ? applyFilter(item.children) : []; const allowThis = perm ? (perm.active || perm.read) : false; const hasChildren = filteredChildren.length > 0; + // If permission allows and has children, show item with filtered children + // If permission allows but no children, show item + // If no permission but has allowed children, show item with children if (!allowThis && !hasChildren) return null; return { diff --git a/middleware/permissions.ts b/middleware/permissions.ts new file mode 100644 index 0000000..2749513 --- /dev/null +++ b/middleware/permissions.ts @@ -0,0 +1,239 @@ +// middleware/permissions.ts +// Auto-save user permissions to localStorage when user is authenticated +import { defineNuxtRouteMiddleware } from '#app'; +import { useLocalStorage } from '@vueuse/core'; +import { useNavItemsStore } from '~/stores/navItems1'; + +interface NavItem { + id: number; + name: string; + path: string; + icon: string; + children?: NavItem[]; +} + +interface BackendPermission { + id: number; + create: boolean; + read: boolean; + update: boolean; + disable: boolean; + delete: boolean; + active: boolean; + pagename: string; + pagesID: number; + level?: number; + sort?: number; + parent?: number; +} + +interface PermissionResponse { + message?: string; + data?: BackendPermission[]; + meta?: { + count: number; + total: number; + }; + error?: string; +} + +// Save permissions to allHakAksesData in localStorage +const savePermissionsToHakAksesData = async ( + backendPermissions: BackendPermission[], + role: string, + group: string +) => { + try { + // Get user data for additional info + const userData = await $fetch('/api/users/current').catch(() => null); + + // Get existing hak akses data from localStorage + const allHakAksesData = useLocalStorage('allHakAksesData', []); + + // Check if entry already exists for this role+group combination + const existingIndex = allHakAksesData.value.findIndex( + (item) => item.role === role && item.group === group + ); + + // Get navItemsStore to build menu template + const navItemsStore = useNavItemsStore(); + + // Build menu template from navItems + const buildMenuTemplate = (items: NavItem[]): any[] => { + const result: any[] = []; + const walk = (list: NavItem[]) => { + list.forEach((item) => { + result.push({ + name: item.name, + canAccess: false, + canView: false, + canAdd: false, + canEdit: false, + canDelete: false, + }); + if (item.children?.length) { + walk(item.children); + } + }); + }; + walk(items); + return result; + }; + + const menuTemplate = buildMenuTemplate(navItemsStore.navItems); + + // Map backend permissions to menu items + const mappedPermissions = menuTemplate.map((menu) => { + // Find matching permission from backend (by pagename or menu name) + const backendPerm = backendPermissions.find((perm) => + perm.pagename?.toLowerCase() === menu.name.toLowerCase() || + perm.pagename?.toLowerCase().includes(menu.name.toLowerCase()) || + menu.name.toLowerCase().includes(perm.pagename?.toLowerCase() || '') + ); + + if (backendPerm) { + return { + name: menu.name, + canAccess: backendPerm.active || backendPerm.read || false, + canView: backendPerm.read || false, + canAdd: backendPerm.create || false, + canEdit: backendPerm.update || false, + canDelete: backendPerm.delete || false, + }; + } + return menu; + }); + + // Create hak akses data entry + const hakAksesEntry = { + id: existingIndex > -1 ? allHakAksesData.value[existingIndex].id : + (allHakAksesData.value.length > 0 + ? Math.max(...allHakAksesData.value.map(i => i.id || 0)) + 1 + : 1), + userId: userData?.id || '', + namaLengkap: userData?.namaLengkap || '', + namaUser: userData?.namaUser || '', + tipeUser: userData?.tipeUser || '', + role: role, + group: group, + namaTipeUser: userData?.tipeUser || role, + hakAksesMenu: mappedPermissions, + // Store backend permissions for reference + backendPermissions: backendPermissions, + }; + + if (existingIndex > -1) { + // Update existing entry + allHakAksesData.value[existingIndex] = hakAksesEntry; + console.log('✅ [Permissions Middleware] Updated existing hak akses data for', role, '/', group); + } else { + // Add new entry + allHakAksesData.value.push(hakAksesEntry); + console.log('✅ [Permissions Middleware] Added new hak akses data for', role, '/', group); + } + + console.log('💾 [Permissions Middleware] Permissions saved to allHakAksesData:', { + role, + group, + permissionsCount: backendPermissions.length, + menuCount: mappedPermissions.length, + }); + } catch (error) { + console.error('❌ [Permissions Middleware] Error saving permissions to hak akses data:', error); + } +}; + +// Fetch permissions from backend API and save to localStorage +const fetchAndSavePermissions = async () => { + // Skip on server-side + if (process.server) { + return; + } + + // Check if we've already processed permissions in this session + const sessionKey = 'permissions_synced'; + if (sessionStorage.getItem(sessionKey)) { + console.log('⏭️ [Permissions Middleware] Permissions already synced in this session'); + return; + } + + try { + // Get current user data with roles and groups + const userData = await $fetch('/api/users/current').catch(() => null); + + if (!userData) { + console.warn('⚠️ [Permissions Middleware] No user data found'); + return; + } + + const roles = [ + ...(userData.realmRoles || []), + ...(userData.roles || []), + ]; + + // Extract groups from paths (e.g., "/Instalasi STIM/Devops/Superadmin" -> "STIM") + const groups: string[] = []; + (userData.groups || []).forEach((g: string) => { + const parts = g.split('/').filter(Boolean); + if (parts.length > 1) { + groups.push(parts[1]); // Get second part as group name + } else if (parts.length === 1) { + groups.push(parts[0]); + } + }); + + if (roles.length === 0 || groups.length === 0) { + console.warn('⚠️ [Permissions Middleware] No roles or groups found for current user'); + return; + } + + // Use first role and first group + const primaryRole = roles[0] || ''; + const primaryGroup = groups[0] || ''; + + if (!primaryRole || !primaryGroup) { + return; + } + + console.log('🔄 [Permissions Middleware] Fetching permissions for', primaryRole, '/', primaryGroup); + + // Fetch permissions from API + const response = await $fetch('/api/permission', { + query: { + roles: primaryRole, + groups: primaryGroup, + }, + }); + + if (response && response.data && Array.isArray(response.data) && response.data.length > 0) { + // Save permissions to localStorage + await savePermissionsToHakAksesData(response.data, primaryRole, primaryGroup); + + // Mark as synced in this session + sessionStorage.setItem(sessionKey, 'true'); + console.log('✅ [Permissions Middleware] Permissions synced successfully'); + } else { + console.warn('⚠️ [Permissions Middleware] No permissions data received from API'); + } + } catch (error) { + console.error('❌ [Permissions Middleware] Error fetching/saving permissions:', error); + } +}; + +export default defineNuxtRouteMiddleware(async (to) => { + // Only run on client-side + if (process.server) { + return; + } + + // Skip for login page + if (to.path === '/LoginPage') { + return; + } + + // Run async permission sync (non-blocking) + // This will only run once per session due to sessionStorage check + fetchAndSavePermissions().catch(err => { + console.error('❌ [Permissions Middleware] Failed to sync permissions:', err); + }); +}); diff --git a/nuxt.config.ts b/nuxt.config.ts index d7f8e1e..79f249b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -49,7 +49,8 @@ export default defineNuxtConfig({ keycloakClientSecret: process.env.KEYCLOAK_CLIENT_SECRET, keycloakIssuer: process.env.KEYCLOAK_ISSUER, public: { - authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.114:3001", + authUrl: process.env.AUTH_ORIGIN, + // authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001", // authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001", }, }, @@ -64,7 +65,7 @@ export default defineNuxtConfig({ "~/assets/scss/main.scss", ], devServer: { - host: "http://10.10.150.114", // Changed from "10.10.123.139" + host: "http://10.10.150.175", // Changed from "10.10.123.139" port: 3001 }, diff --git a/pages/CheckInPasien/checkIn.vue b/pages/CheckInPasien/checkIn.vue index 6d681fc..d514a34 100644 --- a/pages/CheckInPasien/checkIn.vue +++ b/pages/CheckInPasien/checkIn.vue @@ -70,16 +70,17 @@ - - mdi-camera - Simulasi Scan QR - +
+ + mdi-camera + Simulasi Scan QR + +
@@ -95,6 +96,26 @@ Tips: Pastikan pencahayaan cukup untuk hasil scan optimal
+ + +
+

Akses Cepat

+ + + + mdi-history + Riwayat + + + +
@@ -131,47 +152,37 @@ - - mdi-login - Check-in Sekarang - +
+ + mdi-login + Check-in Sekarang + +

Akses Cepat

- + mdi-history Riwayat - - - mdi-help-circle - Bantuan - -
@@ -214,17 +225,18 @@ density="comfortable" > - - mdi-qrcode-plus - Generate QR Code - +
+ + mdi-qrcode-plus + Generate QR Code + +
@@ -493,6 +505,167 @@ + + + +
+

+ mdi-history + Riwayat Check-in +

+

Daftar check-in yang telah dilakukan

+
+ + + +
+ + + + + + + + + + mdi-delete + Hapus Semua + + + +
+ + +
+ + +
+
+
+ + {{ getStatusIcon(item.status) }} + {{ getStatusText(item.status) }} + + + {{ item.method }} + +
+ +
+

+ mdi-account-circle + ID Pasien: {{ item.patientId }} +

+

+ mdi-ticket + Nomor Antrean: {{ item.queueNumber }} +

+
+ +
+ + mdi-clock-outline + {{ formatDateTime(item.checkInTime) }} + + + mdi-calendar + {{ formatDate(item.checkInDate) }} + +
+
+ +
+ + mdi-delete-outline + +
+
+
+
+
+ + +
+ mdi-history +

Belum ada riwayat check-in

+

Riwayat check-in akan muncul di sini setelah Anda melakukan check-in

+
+
+ + + + + Tutup + + +
+
+ @@ -500,9 +673,11 @@ import { ref, computed, nextTick } from 'vue'; definePageMeta({ - middleware:['auth'] + middleware:['auth'], + layout: false, }) + // TypeScript declaration for QRCode declare global { interface Window { @@ -527,6 +702,25 @@ const scannedData = ref(null); const manualInput = ref(''); const manualForm = ref(null); +// History Dialog +const historyDialog = ref(false); +const historySearch = ref(''); +const historyStatusFilter = ref(''); +const checkInHistory = ref>([]); + +const historyStatusOptions = [ + { title: 'Berhasil', value: 'success' }, + { title: 'Gagal', value: 'failed' }, + { title: 'Pending', value: 'pending' } +]; + // Generate QR variables const generatePatientId = ref('P12345'); const generateStatus = ref('ALLOWED'); @@ -585,7 +779,19 @@ const handleInfoAction = async () => { const performCheckIn = async (data: string): Promise => { await new Promise(resolve => setTimeout(resolve, 1000)); - return Math.random() < 0.8; + const success = Math.random() < 0.8; + + // Simpan ke history (baik berhasil maupun gagal) + const [patientId, status] = data.split('|'); + saveToHistory({ + patientId: patientId || 'Unknown', + status: success ? (status || 'ALLOWED') : 'failed', + checkInTime: new Date().toISOString(), + checkInDate: new Date().toISOString(), + method: scannedData.value ? 'QR Scan' : 'Manual' + }); + + return success; }; const showSnackbar = (title: string, message: string, color: string, icon: string) => { @@ -596,9 +802,23 @@ const showSnackbar = (title: string, message: string, color: string, icon: strin snackbar.value.show = true; }; -const checkInManual = () => { - if (manualForm.value) { - showSnackbar('Info', 'Check-in Manual sedang diproses.', 'info', 'mdi-information'); +const checkInManual = async () => { + if (!manualInput.value) { + showSnackbar('Error', 'Mohon isi nomor antrean atau ID pasien', 'error', 'mdi-alert'); + return; + } + + // Simulasi check-in manual + const success = await performCheckIn(`${manualInput.value}|ALLOWED`); + + if (success) { + showSnackbar('Berhasil!', 'Check-in manual berhasil dilakukan.', 'success', 'mdi-check-circle'); + manualInput.value = ''; + if (manualForm.value) { + (manualForm.value as any).reset(); + } + } else { + showSnackbar('Gagal!', 'Check-in manual gagal dilakukan. Silakan coba lagi!', 'error', 'mdi-close-circle'); } }; @@ -686,6 +906,143 @@ const shareQR = async () => { }); } }; + +// History Functions +const HISTORY_STORAGE_KEY = 'checkin_history'; + +const loadHistory = () => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(HISTORY_STORAGE_KEY); + if (stored) { + try { + checkInHistory.value = JSON.parse(stored); + } catch (e) { + console.error('Error loading history:', e); + checkInHistory.value = []; + } + } + } +}; + +const saveToHistory = (item: { + patientId: string; + queueNumber?: string; + status: string; + checkInTime: string; + checkInDate: string; + method: string; +}) => { + const historyItem = { + ...item, + queueNumber: item.queueNumber || `ANT-${Date.now()}`, + }; + + checkInHistory.value.unshift(historyItem); + + // Simpan maksimal 100 item + if (checkInHistory.value.length > 100) { + checkInHistory.value = checkInHistory.value.slice(0, 100); + } + + if (typeof window !== 'undefined') { + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value)); + } +}; + +const deleteHistoryItem = (index: number) => { + checkInHistory.value.splice(index, 1); + if (typeof window !== 'undefined') { + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(checkInHistory.value)); + } + showSnackbar('Berhasil', 'Riwayat berhasil dihapus', 'success', 'mdi-check'); +}; + +const clearHistory = () => { + checkInHistory.value = []; + if (typeof window !== 'undefined') { + localStorage.removeItem(HISTORY_STORAGE_KEY); + } + showSnackbar('Berhasil', 'Semua riwayat berhasil dihapus', 'success', 'mdi-check'); +}; + +const openHistoryDialog = () => { + loadHistory(); + historyDialog.value = true; +}; + +const filteredHistory = computed(() => { + let filtered = [...checkInHistory.value]; + + // Filter by search + if (historySearch.value) { + const search = historySearch.value.toLowerCase(); + filtered = filtered.filter(item => + item.patientId.toLowerCase().includes(search) || + (item.queueNumber && item.queueNumber.toLowerCase().includes(search)) + ); + } + + // Filter by status + if (historyStatusFilter.value) { + filtered = filtered.filter(item => { + if (historyStatusFilter.value === 'success') { + return item.status === 'ALLOWED' || item.status === 'success'; + } else if (historyStatusFilter.value === 'failed') { + return item.status === 'NOT_ALLOWED' || item.status === 'failed'; + } + return true; + }); + } + + return filtered; +}); + +const getStatusColor = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'success'; + if (status === 'NOT_ALLOWED' || status === 'failed') return 'error'; + return 'warning'; +}; + +const getStatusIcon = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'mdi-check-circle'; + if (status === 'NOT_ALLOWED' || status === 'failed') return 'mdi-close-circle'; + return 'mdi-clock-alert'; +}; + +const getStatusText = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'Berhasil'; + if (status === 'NOT_ALLOWED' || status === 'failed') return 'Gagal'; + return 'Pending'; +}; + +const getStatusClass = (status: string) => { + if (status === 'ALLOWED' || status === 'success') return 'history-success'; + if (status === 'NOT_ALLOWED' || status === 'failed') return 'history-failed'; + return 'history-pending'; +}; + +const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString('id-ID', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +}; + +const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric' + }); +}; + +// Load history on mount +if (typeof window !== 'undefined') { + loadHistory(); +}