@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +122 -2
  3. package/package.json +44 -4
  4. package/src/components/ai/AppAssistantDraft.vue +120 -0
  5. package/src/components/ai/AppAssistantWidget.vue +245 -0
  6. package/src/components/ai/AppCitedAnswer.vue +86 -0
  7. package/src/components/approvals/AppApprovalInbox.vue +265 -0
  8. package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
  9. package/src/components/chat/AppChatBubble.vue +324 -0
  10. package/src/components/chat/AppChatList.vue +249 -0
  11. package/src/components/chat/AppChatPanel.vue +315 -0
  12. package/src/components/chat/AppChatWidget.vue +126 -0
  13. package/src/components/communications/AppChannelBadge.vue +47 -0
  14. package/src/components/communications/AppConversationThread.vue +439 -0
  15. package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
  16. package/src/components/customFields/CustomFieldInput.vue +422 -0
  17. package/src/components/customFields/CustomFieldValue.vue +129 -0
  18. package/src/components/customFields/CustomFieldsSection.vue +65 -0
  19. package/src/components/customFields/fieldTypes.js +23 -0
  20. package/src/components/dashboard/AppCustomWidget.vue +138 -0
  21. package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
  22. package/src/components/dashboard/AppDashboardGrid.vue +200 -0
  23. package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
  24. package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
  25. package/src/components/dataTools/AppImportExport.vue +724 -0
  26. package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
  27. package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
  28. package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
  29. package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
  30. package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
  31. package/src/components/kpi/AppKpiAppraisals.vue +733 -0
  32. package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
  33. package/src/components/kpi/AppKpiEntryModal.vue +122 -0
  34. package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
  35. package/src/components/kpi/AppKpiMyCard.vue +134 -0
  36. package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
  37. package/src/components/kpi/AppKpiReportTable.vue +214 -0
  38. package/src/components/kpi/AppKpiScorecard.vue +242 -0
  39. package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
  40. package/src/components/kpi/AppKpiTeam.vue +221 -0
  41. package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
  42. package/src/components/kpi/AppKpiWorkspace.vue +479 -0
  43. package/src/components/layout/AppGlobalSearch.vue +149 -0
  44. package/src/components/layout/AppLanguagePopup.vue +94 -0
  45. package/src/components/layout/AppLauncherPopup.vue +147 -0
  46. package/src/components/layout/AppLegalConsentModal.vue +484 -0
  47. package/src/components/layout/AppNavbar.vue +402 -0
  48. package/src/components/layout/AppNotificationContent.vue +173 -0
  49. package/src/components/layout/AppNotificationDrawer.vue +98 -0
  50. package/src/components/layout/AppNotificationPopup.vue +67 -0
  51. package/src/components/layout/AppPlatformBanners.vue +192 -0
  52. package/src/components/layout/AppSidebar.vue +226 -0
  53. package/src/components/layout/AppSidebarNav.vue +119 -0
  54. package/src/components/layout/AppUserMenu.vue +89 -0
  55. package/src/components/layout/AppUserMenuContent.vue +59 -0
  56. package/src/composables/useKpi.js +232 -0
  57. package/src/composables/useLgViewport.js +25 -0
  58. package/src/composables/useNavigation.js +177 -0
  59. package/src/layouts/AppLayout.vue +207 -0
  60. package/src/locales/en.json +1362 -0
  61. package/src/locales/id.json +1396 -0
  62. package/src/pages/auth/AccountBlocked.vue +55 -0
  63. package/src/pages/auth/IdentityRedirect.vue +37 -0
  64. package/src/pages/profile/Layout.vue +210 -0
  65. package/src/pages/profile/partials/AccountSection.vue +139 -0
  66. package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
  67. package/src/pages/profile/partials/DevicesCard.vue +222 -0
  68. package/src/pages/profile/partials/PreferencesSection.vue +87 -0
  69. package/src/pages/profile/partials/SecuritySection.vue +93 -0
  70. package/src/pages/profile/partials/SessionsCard.vue +418 -0
  71. package/src/pages/profile/partials/SessionsSection.vue +58 -0
  72. package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
  73. package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
  74. package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
  75. package/src/shellSetup.js +46 -0
@@ -0,0 +1,87 @@
1
+ <script setup>
2
+ import { useI18n } from 'vue-i18n'
3
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
4
+ import { usePreferences } from '@initsolusidigital/worksuite-core/composables/usePreferences.js'
5
+ import { computed } from 'vue'
6
+ import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
7
+ import AppToggle from '@initsolusidigital/worksuite-ui/AppToggle.vue'
8
+
9
+ // Section "Preferensi" — bahasa + tema. PER-USER, tersimpan BACKEND (users.preferences via
10
+ // PUT /profile/preferences) → SYNC lintas app/browser/device. usePreferences.save()
11
+ // apply lokal seketika + cache localStorage + PUT. Berlaku instan, tanpa tombol simpan.
12
+ const { t, locale } = useI18n()
13
+ const appStore = useAppStore()
14
+ const prefs = usePreferences()
15
+
16
+ // Daftar bahasa + kelas bendera `flag-icons` — SAMA PERSIS dengan AppLanguagePopup di navbar.
17
+ // Dua pintu untuk satu setelan (popup navbar & halaman Profil) harus terlihat sama; kalau tidak,
18
+ // user menyangka keduanya mengatur hal yang berbeda.
19
+ // ⚠️ Nama bahasa diambil dari kamus (`lang.*_full`), BUKAN ditulis tetap seperti sebelumnya:
20
+ // nilai tetap membuat "English" tampil begitu saja walau antarmuka berbahasa Indonesia,
21
+ // sementara popup navbar di layar yang sama menampilkan "Inggris (AS)".
22
+ const LANGS = computed(() => [
23
+ { value: 'id', label: t('lang.id_full'), flag: 'fi fi-id' },
24
+ { value: 'en', label: t('lang.en_full'), flag: 'fi fi-us' },
25
+ ])
26
+
27
+ function setLang(code) {
28
+ prefs.save({ locale: code })
29
+ }
30
+ function toggleTheme() {
31
+ prefs.save({ theme: appStore.darkMode ? 'light' : 'dark' })
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
37
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.preferences.title') }}</h2>
38
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 mb-5">{{ t('profile.preferences.subtitle') }}</p>
39
+
40
+ <div class="divide-y divide-gray-100 dark:divide-gray-700">
41
+ <!-- Bahasa -->
42
+ <div class="flex items-center justify-between gap-4 py-4 first:pt-0">
43
+ <div>
44
+ <p class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ t('profile.preferences.language') }}</p>
45
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.preferences.languageHint') }}</p>
46
+ </div>
47
+ <div class="w-56 shrink-0">
48
+ <!-- AppCombobox, bukan AppSelect: daftar opsi <select> digambar OS sehingga bendera
49
+ mustahil dirender di dalamnya. Slot `option-icon`
50
+ menaruh bendera di daftar DAN di label terpilih sekaligus. -->
51
+ <AppCombobox
52
+ :model-value="locale"
53
+ :options="LANGS"
54
+ option-value="value"
55
+ option-label="label"
56
+ :aria-label="t('profile.preferences.language')"
57
+ :placeholder="''"
58
+ hide-clear
59
+ dense
60
+ @update:model-value="setLang"
61
+ >
62
+ <template #option-icon="{ option }">
63
+ <span
64
+ :class="option.flag"
65
+ style="width:20px;height:14px;border-radius:2px;flex-shrink:0;display:inline-block;"
66
+ />
67
+ </template>
68
+ </AppCombobox>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Tema -->
73
+ <div class="flex items-center justify-between gap-4 py-4">
74
+ <div>
75
+ <p class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ t('profile.preferences.theme') }}</p>
76
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.preferences.themeHint') }}</p>
77
+ </div>
78
+ <AppToggle
79
+ color="blue"
80
+ :model-value="appStore.darkMode"
81
+ :label="appStore.darkMode ? t('profile.preferences.dark') : t('profile.preferences.light')"
82
+ @toggle="toggleTheme"
83
+ />
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
@@ -0,0 +1,93 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { authApi } from '@initsolusidigital/worksuite-core/api/endpoints/auth.js'
5
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
6
+ import { useFormSubmit } from '@initsolusidigital/worksuite-core/composables/useFormSubmit.js'
7
+ import { useRetryCountdown } from '@initsolusidigital/worksuite-core/composables/useRetryCountdown.js'
8
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
9
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
10
+ import TwoFactorSection from './TwoFactorSection.vue'
11
+ import { Mail, Send } from '@lucide/vue'
12
+
13
+ // Password diganti lewat tautan ke email pemilik akun, bukan di layar ini. Sesi yang tertinggal
14
+ // terbuka di perangkat orang lain tak cukup untuk mengganti password: yang dibutuhkan kotak masuk
15
+ // email-nya. Tautan membuka layar atur ulang di pintu identitas, sama dengan alur lupa password.
16
+ const { t } = useI18n()
17
+ const authStore = useAuthStore()
18
+ const { formError, saving, retryIn, submit } = useFormSubmit()
19
+
20
+ const email = computed(() => authStore.user?.email ?? '')
21
+
22
+ // Masa berlaku tautan mengikuti `auth.passwords.users.expire` backend (bawaan 60 menit). Angka
23
+ // sebenarnya tertulis di email yang diterima; kalimat di layar ini hanya pengantar.
24
+ const LINK_TTL_MINUTES = 60
25
+
26
+ // Backend menolak permintaan kedua untuk identitas yang sama selama 60 detik TANPA memberi tahu:
27
+ // `POST /forgot-password` selalu menjawab 200 supaya tak bisa dipakai menebak email terdaftar.
28
+ // Tombol yang tetap aktif di jeda itu menjanjikan email yang tak pernah dikirim, jadi tombolnya
29
+ // dikunci sepanjang jeda yang sama.
30
+ const RESEND_COOLDOWN_SECONDS = 60
31
+ const cooldown = useRetryCountdown()
32
+ const sent = computed(() => cooldown.active.value)
33
+ const waitSeconds = computed(() => Math.max(cooldown.seconds.value, retryIn.value ?? 0))
34
+
35
+ async function sendLink() {
36
+ if (!email.value || waitSeconds.value > 0) return
37
+ await submit(() => authApi.forgotPassword(email.value), {
38
+ successKey: 'profile.security.linkSent',
39
+ errorKey: 'profile.security.sendError',
40
+ onSuccess: () => cooldown.start(RESEND_COOLDOWN_SECONDS),
41
+ })
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <div class="space-y-6">
47
+ <section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-clip">
48
+ <header class="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
49
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.security.title') }}</h2>
50
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.security.subtitle') }}</p>
51
+ </header>
52
+
53
+ <div class="px-6 py-5 space-y-4">
54
+ <AppAlert v-if="formError" variant="error" :message="formError" />
55
+
56
+ <p class="text-sm text-gray-700 dark:text-gray-300">
57
+ {{ t('profile.security.explain', { minutes: LINK_TTL_MINUTES }) }}
58
+ </p>
59
+
60
+ <div class="flex items-center gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700">
61
+ <span class="flex items-center justify-center w-9 h-9 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 shrink-0" aria-hidden="true">
62
+ <Mail class="w-4 h-4" />
63
+ </span>
64
+ <div class="min-w-0">
65
+ <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('profile.security.sentTo') }}</p>
66
+ <p class="text-sm font-medium text-gray-900 dark:text-white truncate" :title="email">{{ email }}</p>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- Status kiriman diumumkan pembaca layar; toast saja hilang sebelum sempat dibaca. -->
71
+ <p v-if="sent" class="text-sm text-green-700 dark:text-green-400" role="status">
72
+ {{ t('profile.security.sentNote') }}
73
+ </p>
74
+
75
+ <div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
76
+ <p>{{ t('profile.security.signOutNote') }}</p>
77
+ <p>{{ t('profile.security.help') }}</p>
78
+ </div>
79
+ </div>
80
+
81
+ <footer class="flex justify-end px-6 py-4 bg-gray-50 dark:bg-gray-900/40 border-t border-gray-100 dark:border-gray-700">
82
+ <AppButton variant="primary" :loading="saving" :disabled="!email || waitSeconds > 0" @click="sendLink">
83
+ <Send class="w-4 h-4 mr-1.5" aria-hidden="true" />
84
+ {{ waitSeconds > 0 ? t('profile.security.resendIn', { seconds: waitSeconds }) : t('profile.security.sendButton') }}
85
+ </AppButton>
86
+ </footer>
87
+ </section>
88
+
89
+ <!-- 2FA di layar yang SAMA dengan password: keduanya "cara masuk ke akun ini", dan memisahkannya
90
+ jadi tab sendiri membuat orang mencari 2FA di Preferensi. -->
91
+ <TwoFactorSection />
92
+ </div>
93
+ </template>
@@ -0,0 +1,418 @@
1
+ <script setup>
2
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { Monitor, Tablet, Smartphone, HelpCircle, History, LogOut, ChevronRight, ChevronDown, ChevronLeft } from '@lucide/vue'
5
+ import { Icon } from '@iconify/vue'
6
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
7
+ import { usersApi } from '@initsolusidigital/worksuite-core/api/endpoints/users.js'
8
+ import { browserIconData, osIconData } from '@initsolusidigital/worksuite-core/data/deviceIcons.js'
9
+ import { useActivityFieldFormat } from '@initsolusidigital/worksuite-core/composables/useActivityFieldFormat.js'
10
+ import AppExpandableTable from '@initsolusidigital/worksuite-ui/AppExpandableTable.vue'
11
+ import AppEmptyState from '@initsolusidigital/worksuite-ui/AppEmptyState.vue'
12
+ import AppTooltip from '@initsolusidigital/worksuite-ui/AppTooltip.vue'
13
+ import AppIconButton from '@initsolusidigital/worksuite-ui/AppIconButton.vue'
14
+ import AppForceLogoutModal from '@initsolusidigital/worksuite-core/components/ui/AppForceLogoutModal.vue'
15
+
16
+ const { t, locale } = useI18n()
17
+ const authStore = useAuthStore()
18
+ const { fieldLabel, changeText } = useActivityFieldFormat()
19
+
20
+ const companyId = computed(() => authStore.user?.company_id)
21
+ const userId = computed(() => authStore.user?.id)
22
+
23
+ // ── State tabel utama — sesi login, server-side pagination ────────────────────
24
+ const sessions = ref([])
25
+ const totalRows = ref(0)
26
+ const loading = ref(false)
27
+ const pageError = ref('')
28
+ const page = ref(1)
29
+ const perPage = ref(10)
30
+
31
+ // ── State operasi per sesi (lazy-load saat di-expand) ──────────────────────────
32
+ const operationsMap = ref({}) // { [loginId]: [] }
33
+ const opsMeta = ref({}) // { [loginId]: { page, lastPage, total } } — paginasi log operasi
34
+ const loadingOps = ref({})
35
+ const opsError = ref({})
36
+
37
+ const columns = [
38
+ { key: 'login_time', label: t('profile.sessions.colLoginTime') },
39
+ { key: 'device', label: t('profile.sessions.colDevice') },
40
+ { key: 'ip_address', label: t('profile.sessions.colIp') },
41
+ { key: 'status', label: t('profile.sessions.colStatus'), align: 'right' },
42
+ ]
43
+
44
+ const deviceIcons = { 1: Monitor, 2: Tablet, 3: Smartphone, 4: HelpCircle }
45
+
46
+ function deviceLabel(deviceType) {
47
+ return t(`common.deviceType.${deviceType ?? 4}`)
48
+ }
49
+
50
+ // Tooltip browser — pakai browser_name (BUKAN browser_family) + browser_version.
51
+ // browser_name sudah bersih tanpa versi terbawa (matomo/device-detector). browser_family JUSTRU menyesatkan utk Edge: matomo mengelompokkan Microsoft
52
+ // Edge (Chromium) ke family "Internet Explorer" (short_name "PS" ada di grup IE bawaan matomo) —
53
+ // kalau dipakai, tooltip Edge malah bunyi "Internet Explorer". browser_name granular & akurat
54
+ // ("Microsoft Edge", "Firefox", dst), dipakai juga utk cari icon-nya di browserIconData.
55
+ function browserTooltip(row) {
56
+ return [row.browser_name, row.browser_version].filter(Boolean).join(' ') || t('common.unknown')
57
+ }
58
+
59
+ // Tooltip OS — SENGAJA cuma nama family (Windows/macOS/Android/dst), TANPA versi. os_name/
60
+ // os_version dari backend kadang tidak akurat (mis. Windows 11 asli kebaca "Windows 10",
61
+ // Android 16 kebaca "Android 10") — os_family jauh lebih bisa diandalkan krn cuma klasifikasi
62
+ // kasar, tidak coba menebak versi spesifik.
63
+ function osTooltip(row) {
64
+ return row.os_family || t('common.unknown')
65
+ }
66
+
67
+ // Status badge — 4 varian: percobaan gagal (password salah)/ditolak (can_access_hub=false) tidak
68
+ // pernah punya sesi aktif/logout, jadi dicek duluan sebelum lihat logged_out_at.
69
+ function sessionStatusLabel(row) {
70
+ if (row.status === 'failed') return t('common.loginStatus.failed')
71
+ if (row.status === 'denied') return t('common.loginStatus.denied')
72
+ if (row.logged_out_at) return t('profile.sessions.statusEnded')
73
+ // is_current dari backend — dicocokkan dari token Bearer request ini sendiri, bukan tebakan
74
+ // IP/device (banyak sesi bisa share IP/device yang sama, terutama abis testing/QA)
75
+ return row.is_current ? t('profile.sessions.statusActiveCurrent') : t('profile.sessions.statusActive')
76
+ }
77
+
78
+ function sessionStatusClass(row) {
79
+ if (row.status === 'failed') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
80
+ if (row.status === 'denied') return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300'
81
+ if (row.logged_out_at) return 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
82
+ // Sesi saat ini dapat ring tipis biar sedikit menonjol dari sesi aktif lain
83
+ return row.is_current
84
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 ring-1 ring-green-400 dark:ring-green-500'
85
+ : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
86
+ }
87
+
88
+ // Tooltip kapan terakhir aktif — cuma relevan utk sesi yang benar-benar Aktif (bukan Berakhir/
89
+ // Gagal/Ditolak). last_activity_at di-throttle 5 menit di backend (TrackLastActivity middleware),
90
+ // jadi bukan real-time detik, tapi cukup akurat utk indikasi "masih dipakai atau tidak"
91
+ function sessionStatusTooltip(row) {
92
+ if (row.status !== 'success' || row.logged_out_at) return ''
93
+ return row.last_activity_at
94
+ ? t('common.lastActiveAt', { time: formatDateTime(row.last_activity_at) })
95
+ : t('common.lastActiveUnknown')
96
+ }
97
+
98
+ // ── Force Logout (v0.20.1) — sesi sendiri, selalu diizinkan oleh backend ──────
99
+ const forceLogoutTarget = ref(null)
100
+
101
+ function onForced(updatedSession) {
102
+ forceLogoutTarget.value = null
103
+ const idx = sessions.value.findIndex(s => s.id === updatedSession.id)
104
+ if (idx !== -1) sessions.value[idx] = { ...sessions.value[idx], ...updatedSession }
105
+ }
106
+
107
+ function formatDateTime(iso) {
108
+ if (!iso) return '—'
109
+ return new Date(iso).toLocaleString(locale.value === 'id' ? 'id-ID' : 'en-US', {
110
+ dateStyle: 'medium',
111
+ timeStyle: 'short',
112
+ })
113
+ }
114
+
115
+ // ── Fetch sesi login milik sendiri ─────────────────────────────────────────────
116
+ // silent=true dipakai oleh polling background (lihat onMounted) — tidak toggle loading/error
117
+ // state biar tabel tidak flicker & tidak menutupi baris yang sedang di-expand tiap refresh
118
+ async function fetchSessions({ silent = false } = {}) {
119
+ if (!companyId.value || !userId.value) return
120
+ if (!silent) {
121
+ loading.value = true
122
+ pageError.value = ''
123
+ }
124
+ try {
125
+ const { data } = await usersApi.getLoginSessions(companyId.value, userId.value, {
126
+ rowsPerPage: perPage.value,
127
+ page: page.value,
128
+ })
129
+ const payload = data.data
130
+ sessions.value = payload?.data ?? []
131
+ totalRows.value = payload?.total ?? sessions.value.length
132
+ } catch {
133
+ if (!silent) pageError.value = t('profile.sessions.loadError')
134
+ } finally {
135
+ if (!silent) loading.value = false
136
+ }
137
+ }
138
+
139
+ function onPageChange(p) {
140
+ page.value = p
141
+ fetchSessions()
142
+ }
143
+
144
+ function onPerPageChange(val) {
145
+ perPage.value = val
146
+ page.value = 1
147
+ fetchSessions()
148
+ }
149
+
150
+ // ── Lazy-load operasi saat baris di-expand — paginated per sesi (backend memang
151
+ // paginate sejak awal; sebelumnya hanya halaman pertama yang tampil tanpa pager) ──
152
+ const OPS_PER_PAGE = 15
153
+
154
+ async function fetchOperations(loginId, page = 1) {
155
+ loadingOps.value[loginId] = true
156
+ opsError.value[loginId] = ''
157
+ try {
158
+ const { data } = await usersApi.getLoginOperations(companyId.value, userId.value, loginId, {
159
+ rowsPerPage: OPS_PER_PAGE,
160
+ page,
161
+ })
162
+ const pagination = data.data ?? {}
163
+ operationsMap.value[loginId] = pagination.data ?? []
164
+ opsMeta.value[loginId] = { page, lastPage: pagination.last_page ?? 1, total: pagination.total ?? 0 }
165
+ } catch {
166
+ opsError.value[loginId] = t('profile.sessions.expandError')
167
+ } finally {
168
+ loadingOps.value[loginId] = false
169
+ }
170
+ }
171
+
172
+ // Rentang "Menampilkan X–Y dari Z" utk pager operasi
173
+ function opsFrom(loginId) {
174
+ const m = opsMeta.value[loginId]
175
+ return m && m.total ? (m.page - 1) * OPS_PER_PAGE + 1 : 0
176
+ }
177
+ function opsTo(loginId) {
178
+ const m = opsMeta.value[loginId]
179
+ return m ? Math.min(m.page * OPS_PER_PAGE, m.total) : 0
180
+ }
181
+
182
+ async function onExpand(session) {
183
+ if (operationsMap.value[session.id] !== undefined) return
184
+ if (loadingOps.value[session.id]) return
185
+ await fetchOperations(session.id)
186
+ }
187
+
188
+ // ── Expand detail changed_fields per baris operasi (v0.22.0) — op.id unik global (UUID),
189
+ // jadi 1 Set saja cukup lintas semua sesi, tidak perlu di-key per session.id ──────────────
190
+ const expandedOpIds = ref(new Set())
191
+
192
+ function isOpExpanded(op) {
193
+ return expandedOpIds.value.has(op.id)
194
+ }
195
+
196
+ function toggleOpExpand(op) {
197
+ const next = new Set(expandedOpIds.value)
198
+ next.has(op.id) ? next.delete(op.id) : next.add(op.id)
199
+ expandedOpIds.value = next
200
+ }
201
+
202
+ // Badge warna per jenis operasi — hijau dibuat, biru diperbarui, merah dihapus
203
+ function operationBadgeClass(operation) {
204
+ if (operation === 'created') return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
205
+ if (operation === 'updated') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
206
+ if (operation === 'deleted') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
207
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
208
+ }
209
+
210
+ // Nama modul ikut bahasa antarmuka — pakai katalog modules.items.* yang sama dgn halaman Roles,
211
+ // fallback ke nama mentah dari backend kalau slug-nya belum ada di katalog (mis. modul baru)
212
+ function translateModule(slug, name) {
213
+ return t(`modules.items.${slug}`, name)
214
+ }
215
+
216
+ // Polling ringan tiap 15 detik supaya status Aktif/Berakhir & last_activity_at tetap akurat
217
+ // tanpa user perlu refresh manual — dihentikan saat komponen unmount (pindah tab profil)
218
+ let pollTimer = null
219
+ onMounted(() => {
220
+ fetchSessions()
221
+ pollTimer = setInterval(() => fetchSessions({ silent: true }), 15000)
222
+ })
223
+ onUnmounted(() => clearInterval(pollTimer))
224
+ </script>
225
+
226
+ <template>
227
+ <!-- Card Riwayat Login — ditumpuk di bawah kartu identitas, bukan tab terpisah -->
228
+ <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
229
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.sessions.title') }}</h2>
230
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 mb-4">{{ t('profile.sessions.subtitle') }}</p>
231
+
232
+ <div
233
+ v-if="pageError"
234
+ class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400"
235
+ >
236
+ {{ pageError }}
237
+ </div>
238
+
239
+ <AppExpandableTable
240
+ :columns="columns"
241
+ :rows="sessions"
242
+ :loading="loading"
243
+ :loading-text="t('profile.sessions.loading')"
244
+ server-side
245
+ :total-rows="totalRows"
246
+ :per-page="perPage"
247
+ @expand="onExpand"
248
+ @page-change="onPageChange"
249
+ @per-page-change="onPerPageChange"
250
+ >
251
+
252
+ <template #cell-login_time="{ row }">
253
+ <span class="text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
254
+ </template>
255
+
256
+ <template #cell-device="{ row }">
257
+ <!-- Cuma icon (device-type + brand browser + brand OS) — masing-masing tooltip sendiri -->
258
+ <div class="flex items-center gap-2">
259
+ <AppTooltip show-on-touch :text="deviceLabel(row.device_type)">
260
+ <component :is="deviceIcons[row.device_type] ?? HelpCircle" class="w-4 h-4 text-gray-500 dark:text-gray-400 shrink-0" />
261
+ </AppTooltip>
262
+ <!-- Brand tidak dikenali → fallback HelpCircle, BUKAN disembunyikan —
263
+ tooltip tetap tampil (nama mentah dari backend kalau ada, atau "Tidak diketahui"). -->
264
+ <AppTooltip show-on-touch :text="browserTooltip(row)">
265
+ <Icon v-if="browserIconData(row.browser_name)" :icon="browserIconData(row.browser_name)" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
266
+ <HelpCircle v-else class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
267
+ </AppTooltip>
268
+ <AppTooltip show-on-touch :text="osTooltip(row)">
269
+ <Icon v-if="osIconData(row.os_family)" :icon="osIconData(row.os_family)" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
270
+ <HelpCircle v-else class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 shrink-0" />
271
+ </AppTooltip>
272
+ </div>
273
+ </template>
274
+
275
+ <template #cell-ip_address="{ row }">
276
+ <span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{ row.ip_address ?? '—' }}</span>
277
+ </template>
278
+
279
+ <template #cell-status="{ row }">
280
+ <div class="flex items-center justify-end gap-2">
281
+ <AppTooltip show-on-touch :text="sessionStatusTooltip(row)">
282
+ <span
283
+ class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
284
+ :class="sessionStatusClass(row)"
285
+ >
286
+ {{ sessionStatusLabel(row) }}
287
+ </span>
288
+ </AppTooltip>
289
+ <AppIconButton v-if="row.status === 'success' && !row.logged_out_at" :icon="LogOut" :label="t('common.forceLogout.action')" variant="danger" @click="forceLogoutTarget = row" />
290
+ </div>
291
+ </template>
292
+
293
+ <!-- ── Expanded row: aktivitas/operasi selama sesi ini ──────────────── -->
294
+ <template #expanded-row="{ row: session }">
295
+ <div class="px-4 sm:px-12 py-4">
296
+
297
+ <div v-if="loadingOps[session.id]" class="flex items-center gap-2 text-xs text-gray-400 py-2">
298
+ <svg class="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
299
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
300
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
301
+ </svg>
302
+ {{ t('profile.sessions.expandLoading') }}
303
+ </div>
304
+
305
+ <p v-else-if="opsError[session.id]" class="text-xs text-red-500 dark:text-red-400 py-2">
306
+ {{ opsError[session.id] }}
307
+ </p>
308
+
309
+ <p
310
+ v-else-if="operationsMap[session.id]?.length === 0"
311
+ class="text-xs text-gray-400 dark:text-gray-500 py-2"
312
+ >
313
+ {{ t('profile.sessions.expandEmpty') }}
314
+ </p>
315
+
316
+ <div v-else-if="operationsMap[session.id]" class="border border-gray-100 dark:border-gray-700/50 rounded-lg overflow-auto scrollbar-rounded max-h-72">
317
+ <table class="w-full text-xs border-collapse">
318
+ <thead class="sticky top-0 z-10 bg-gray-50 dark:bg-gray-800">
319
+ <tr>
320
+ <th class="text-left px-3 py-2 font-semibold text-gray-400 dark:text-gray-500 border border-gray-100 dark:border-gray-700/50">{{ t('profile.sessions.colOperation') }}</th>
321
+ <th class="text-left px-3 py-2 font-semibold text-gray-400 dark:text-gray-500 border border-gray-100 dark:border-gray-700/50">{{ t('profile.sessions.colDescription') }}</th>
322
+ <th class="text-left px-3 py-2 font-semibold text-gray-400 dark:text-gray-500 border border-gray-100 dark:border-gray-700/50">{{ t('profile.sessions.colModule') }}</th>
323
+ <th class="text-left px-3 py-2 font-semibold text-gray-400 dark:text-gray-500 border border-gray-100 dark:border-gray-700/50">{{ t('profile.sessions.colTime') }}</th>
324
+ </tr>
325
+ </thead>
326
+ <tbody>
327
+ <template v-for="(op, idx) in operationsMap[session.id]" :key="op.id">
328
+ <tr :class="idx % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-gray-800/25'">
329
+ <td class="px-3 py-2.5 border border-gray-100 dark:border-gray-700/50">
330
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" :class="operationBadgeClass(op.operation)">
331
+ {{ t(`common.operation.${op.operation}`, op.operation) }}
332
+ </span>
333
+ </td>
334
+ <td class="px-3 py-2.5 text-gray-700 dark:text-gray-300 border border-gray-100 dark:border-gray-700/50">
335
+ <div class="flex items-center gap-1.5">
336
+ <span>{{ op.op_description }}</span>
337
+ <AppTooltip v-if="op.changed_fields?.length" :text="isOpExpanded(op) ? t('profile.sessions.hideChanges') : t('profile.sessions.viewChanges')">
338
+ <button :aria-label="t('common.toggleDetail')"
339
+ @click="toggleOpExpand(op)"
340
+ class="p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shrink-0"
341
+ >
342
+ <ChevronDown v-if="isOpExpanded(op)" class="w-3.5 h-3.5" />
343
+ <ChevronRight v-else class="w-3.5 h-3.5" />
344
+ </button>
345
+ </AppTooltip>
346
+ </div>
347
+ </td>
348
+ <td class="px-3 py-2.5 text-gray-500 dark:text-gray-400 border border-gray-100 dark:border-gray-700/50">{{ op.module ? translateModule(op.module.slug, op.module.name) : '—' }}</td>
349
+ <td class="px-3 py-2.5 text-gray-500 dark:text-gray-400 border border-gray-100 dark:border-gray-700/50 whitespace-nowrap">{{ formatDateTime(op.created_at) }}</td>
350
+ </tr>
351
+ <tr v-if="isOpExpanded(op)" :class="idx % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-gray-800/25'">
352
+ <td colspan="4" class="px-3 py-2.5 border border-gray-100 dark:border-gray-700/50">
353
+ <ul v-if="op.changed_fields?.length" class="space-y-1">
354
+ <li v-for="cf in op.changed_fields" :key="cf.field" class="text-xs text-gray-600 dark:text-gray-400">
355
+ <span class="font-medium text-gray-500 dark:text-gray-400">{{ fieldLabel(cf.field) }}:</span>
356
+ {{ changeText(cf) }}
357
+ </li>
358
+ </ul>
359
+ <p v-else class="text-xs text-gray-400 dark:text-gray-500">{{ t('profile.sessions.noChanges') }}</p>
360
+ </td>
361
+ </tr>
362
+ </template>
363
+ </tbody>
364
+ </table>
365
+ </div>
366
+
367
+ <!-- Pager operasi — hanya tampil kalau memang lebih dari 1 halaman -->
368
+ <div
369
+ v-if="!loadingOps[session.id] && (opsMeta[session.id]?.lastPage ?? 1) > 1"
370
+ class="flex items-center justify-between gap-3 mt-2"
371
+ >
372
+ <p class="text-[11px] text-gray-400 dark:text-gray-500">
373
+ {{ t('common.pagination', { from: opsFrom(session.id), to: opsTo(session.id), total: opsMeta[session.id].total }) }}
374
+ </p>
375
+ <div class="flex items-center gap-1">
376
+ <button
377
+ type="button"
378
+ :disabled="opsMeta[session.id].page === 1"
379
+ :aria-label="t('common.prevPage')"
380
+ class="p-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
381
+ @click="fetchOperations(session.id, opsMeta[session.id].page - 1)"
382
+ >
383
+ <ChevronLeft class="w-3.5 h-3.5" />
384
+ </button>
385
+ <span class="text-[11px] text-gray-500 dark:text-gray-400 tabular-nums">
386
+ {{ opsMeta[session.id].page }} / {{ opsMeta[session.id].lastPage }}
387
+ </span>
388
+ <button
389
+ type="button"
390
+ :disabled="opsMeta[session.id].page >= opsMeta[session.id].lastPage"
391
+ :aria-label="t('common.nextPage')"
392
+ class="p-1 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
393
+ @click="fetchOperations(session.id, opsMeta[session.id].page + 1)"
394
+ >
395
+ <ChevronRight class="w-3.5 h-3.5" />
396
+ </button>
397
+ </div>
398
+ </div>
399
+
400
+ </div>
401
+ </template>
402
+
403
+ <template #empty>
404
+ <AppEmptyState :label="t('profile.sessions.empty')">
405
+ <template #icon><History class="w-6 h-6 text-gray-400 dark:text-gray-500" :stroke-width="1.5" /></template>
406
+ </AppEmptyState>
407
+ </template>
408
+
409
+ </AppExpandableTable>
410
+
411
+ <AppForceLogoutModal
412
+ v-if="forceLogoutTarget"
413
+ :session="forceLogoutTarget"
414
+ @close="forceLogoutTarget = null"
415
+ @forced="onForced"
416
+ />
417
+ </div>
418
+ </template>
@@ -0,0 +1,58 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { profileApi } from '@initsolusidigital/worksuite-core/api/endpoints/profile.js'
5
+ import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
6
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
7
+ import SessionsCard from './SessionsCard.vue'
8
+ import DevicesCard from './DevicesCard.vue'
9
+ import { LogOut } from '@lucide/vue'
10
+
11
+ // Section "Sesi & Perangkat" — riwayat login (SessionsCard existing) + aksi keluar dari semua
12
+ // perangkat lain (self force-logout, kontrak backend). SessionsCard polling 15 dtk akan
13
+ // mencerminkan sesi lain yang berakhir; +key untuk remount instan setelah aksi.
14
+ const { t } = useI18n()
15
+ const { pushToast } = useToast()
16
+
17
+ const busy = ref(false)
18
+ const cardKey = ref(0) // naikkan → remount SessionsCard (refresh instan pasca logout-others)
19
+
20
+ async function logoutOthers() {
21
+ busy.value = true
22
+ try {
23
+ const { data } = await profileApi.logoutOthers()
24
+ const count = data?.data?.revoked_count ?? 0
25
+ pushToast({
26
+ title: t('profile.sessionsAction.done'),
27
+ body: count > 0 ? t('profile.sessionsAction.revoked', { count }) : t('profile.sessionsAction.none'),
28
+ variant: 'success',
29
+ })
30
+ cardKey.value++
31
+ } catch (err) {
32
+ pushToast({ title: err.response?.data?.message || t('profile.sessionsAction.error'), variant: 'error' })
33
+ } finally {
34
+ busy.value = false
35
+ }
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div class="space-y-4">
41
+ <!-- Perangkat aktif (dulu halaman terpisah /devices, digabung ke sini) -->
42
+ <DevicesCard />
43
+
44
+ <!-- Aksi keamanan sesi -->
45
+ <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
46
+ <div>
47
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.sessionsAction.title') }}</h2>
48
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{{ t('profile.sessionsAction.subtitle') }}</p>
49
+ </div>
50
+ <AppButton variant="secondary" :loading="busy" @click="logoutOthers">
51
+ <LogOut class="w-3.5 h-3.5" />{{ t('profile.sessionsAction.button') }}
52
+ </AppButton>
53
+ </div>
54
+
55
+ <!-- Riwayat login existing -->
56
+ <SessionsCard :key="cardKey" />
57
+ </div>
58
+ </template>