@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,324 @@
1
+ <script setup>
2
+ import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { renderChatMarkup } from '@initsolusidigital/worksuite-core/utils/chatMarkup.js'
5
+ import { useCrossAppLink } from '@initsolusidigital/worksuite-core/composables/useCrossAppLink.js'
6
+ import { companyFilesApi } from '@initsolusidigital/worksuite-core/api/endpoints/companyFiles.js'
7
+ import { FileText, Download, MapPin, ExternalLink, ImageOff, X } from '@lucide/vue'
8
+
9
+ // Isi satu bubble chat — lima tipe: `text` · `image` · `file` · `location` · `entity`.
10
+ //
11
+ // Dipisah dari `AppChatPanel` supaya panel tetap terbaca: lima tipe dengan aturan render
12
+ // masing-masing tak muat jadi rentetan `v-if` di tengah daftar pesan.
13
+ const props = defineProps({
14
+ msg: { type: Object, required: true },
15
+ mine: { type: Boolean, default: false },
16
+ })
17
+
18
+ const { t } = useI18n()
19
+ const { openTarget } = useCrossAppLink()
20
+
21
+ const meta = computed(() => props.msg?.meta ?? {})
22
+ const tipe = computed(() => props.msg?.type || 'text')
23
+
24
+ /**
25
+ * ⚠️ `file_url` DERIVED per respons, bukan tersimpan. `null` + `meta.name` ada berarti berkasnya
26
+ * sudah dihapus (mis. lewat sapu massal File Manager) — **pesannya TIDAK ikut hilang**.
27
+ * Yang ditampilkan: "lampiran telah dihapus: {nama}", memakai nama dari SNAPSHOT `meta`.
28
+ */
29
+ const terhapus = computed(() => !props.msg?.file_url && !!meta.value.name)
30
+
31
+ /** Satu-satunya HTML yang boleh masuk `v-html` — keluaran `renderChatMarkup()`, escape-dulu-baru-dekorasi. */
32
+ const bodyHtml = computed(() => renderChatMarkup(props.msg?.body || ''))
33
+
34
+ const ukuran = (n) => {
35
+ const b = Number(n) || 0
36
+ if (!b) return ''
37
+ if (b < 1024) return `${b} B`
38
+ const u = ['KB', 'MB', 'GB']
39
+ let v = b / 1024, i = 0
40
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
41
+ return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
42
+ }
43
+
44
+ // ── Peta lokasi: Leaflet + OSM, dimuat MALAS ────────────────────────────────────────────────
45
+ // Peta hanya dirakit saat bubble-nya benar-benar terlihat: satu percakapan bisa memuat puluhan
46
+ // pesan lokasi, dan menyiapkan puluhan instance peta sekaligus membuat panel tersendat.
47
+ const petaEl = ref(null)
48
+ let peta = null
49
+ let io = null
50
+
51
+ async function pasangPeta() {
52
+ if (peta || !petaEl.value) return
53
+ const L = (await import('leaflet')).default
54
+ await import('leaflet/dist/leaflet.css')
55
+ const { lat, lng } = meta.value
56
+ peta = L.map(petaEl.value, {
57
+ center: [Number(lat), Number(lng)],
58
+ zoom: 15,
59
+ // Bubble di dalam daftar yang bergulir: peta yang bisa di-zoom/geser akan merebut gulungan.
60
+ zoomControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false,
61
+ keyboard: false, touchZoom: false, attributionControl: true,
62
+ })
63
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
64
+ maxZoom: 19,
65
+ attribution: '&copy; OpenStreetMap',
66
+ }).addTo(peta)
67
+ L.marker([Number(lat), Number(lng)]).addTo(peta)
68
+ }
69
+
70
+ onMounted(() => {
71
+ if (tipe.value !== 'location' || !petaEl.value) return
72
+ io = new IntersectionObserver((entries) => {
73
+ if (entries.some((e) => e.isIntersecting)) { pasangPeta(); io?.disconnect(); io = null }
74
+ })
75
+ io.observe(petaEl.value)
76
+ })
77
+ onBeforeUnmount(() => { io?.disconnect(); peta?.remove?.(); peta = null })
78
+
79
+ // ── Lightbox gambar ─────────────────────────────────────────────────────────────────────────
80
+ // Klik gambar dulu membuka TAB BARU — di situ gambarnya berdiri sendiri tanpa konteks, dan
81
+ // menutupnya berarti meninggalkan percakapan. Lightbox menahannya di tempat & menyediakan unduh.
82
+ const lightbox = ref(false)
83
+
84
+ function tutupLightbox() { lightbox.value = false }
85
+ function onKeydownLightbox(e) { if (e.key === 'Escape') tutupLightbox() }
86
+
87
+ watch(lightbox, (buka) => {
88
+ // Escape ditangani di window, bukan pada elemennya: elemen lightbox belum tentu memegang fokus
89
+ // (user baru saja mengklik gambar di dalam daftar yang bergulir).
90
+ if (buka) window.addEventListener('keydown', onKeydownLightbox)
91
+ else window.removeEventListener('keydown', onKeydownLightbox)
92
+ })
93
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydownLightbox))
94
+
95
+ /**
96
+ * Unduh gambar dengan NAMA ASLI, tanpa meninggalkan percakapan.
97
+ *
98
+ * ⚠️ Atribut `download` pada `<a>` **DIABAIKAN browser untuk URL lintas-origin** — berkas disajikan
99
+ * dari `:8000` sementara aplikasi di `:5173`, jadi tombolnya cuma menavigasi ke gambarnya dan nama
100
+ * yang muncul di dialog Simpan adalah nama STORAGE (`nyos-kitchen-logo-6a698….png`), bukan nama
101
+ * yang diunggah user.
102
+ *
103
+ * Jalur yang benar: ambil sebagai blob lalu simpan dengan nama dari snapshot `meta`. ⚠️ Itu menuntut
104
+ * CORS pada berkasnya, dan **`/storage` TIDAK melewati middleware CORS** (jebakan yang sama dengan
105
+ * `document-print.css` dulu) — terukur: `fetch` dari origin aplikasi gagal `TypeError: Failed to
106
+ * fetch`. Jadi jalur ini baru benar-benar hidup setelah backend menyediakan rute unduh ber-
107
+ * `Content-Disposition`.
108
+ *
109
+ * Sampai itu ada: kita TETAP mencoba blob (kalau kelak berkas satu origin dgn aplikasi, ia langsung
110
+ * bekerja tanpa mengubah kode), lalu jatuh ke membuka tab baru — bukan menavigasi tab yang sedang
111
+ * dipakai, supaya percakapannya tidak hilang.
112
+ */
113
+ const mengunduh = ref(false)
114
+
115
+ /**
116
+ * Alamat unduh — rute API ber-`Content-Disposition`, BUKAN `file_url`.
117
+ *
118
+ * `file_url` menunjuk `/storage` yang tak melewati CORS & mengabaikan atribut `download`; rute ini
119
+ * melayani nama ASLI dari kolom `name` sehingga dialog Simpan menampilkan nama yang diunggah user,
120
+ * bukan nama simpan di disk yang memang opak (`{uuid}.png`).
121
+ */
122
+ const unduhUrl = computed(() => (meta.value.file_id ? companyFilesApi.downloadUrl(meta.value.file_id) : ''))
123
+
124
+ /**
125
+ * Berkas ini bisa diambil sebagai blob?
126
+ *
127
+ * Bukan tebakan: **`/storage` tak pernah melewati middleware CORS**, sedangkan rute di bawah
128
+ * `/api/` melewatinya. Menyaring di sini bukan optimasi melainkan soal kejujuran keluaran — tanpa
129
+ * ini, tiap klik "Unduh" mencetak error CORS merah di konsol padahal fallback-nya bekerja normal,
130
+ * dan konsol yang penuh error palsu membuat error SUNGGUHAN berikutnya tak terlihat.
131
+ */
132
+ function bisaBlob(url) {
133
+ try {
134
+ const u = new URL(url, window.location.href)
135
+ return u.origin === window.location.origin || u.pathname.startsWith('/api/')
136
+ } catch {
137
+ return false
138
+ }
139
+ }
140
+
141
+ async function unduhGambar() {
142
+ if (mengunduh.value) return
143
+ mengunduh.value = true
144
+ const nama = meta.value.name || t('chat.openImage')
145
+ try {
146
+ const alamat = unduhUrl.value || props.msg.file_url
147
+ if (!bisaBlob(alamat)) throw new Error('cors_tertutup')
148
+ const res = await fetch(alamat, { credentials: 'include' })
149
+ if (!res.ok) throw new Error(String(res.status))
150
+ const blob = await res.blob()
151
+ const url = URL.createObjectURL(blob)
152
+ const a = document.createElement('a')
153
+ a.href = url
154
+ a.download = nama
155
+ document.body.appendChild(a)
156
+ a.click()
157
+ a.remove()
158
+ URL.revokeObjectURL(url)
159
+ } catch {
160
+ // Jatuh ke tab baru — jujur & tak merusak apa pun. `noopener` wajib: tanpa itu halaman yang
161
+ // dibuka bisa menyentuh `window.opener`.
162
+ window.open(unduhUrl.value || props.msg.file_url, '_blank', 'noopener')
163
+ } finally {
164
+ mengunduh.value = false
165
+ }
166
+ }
167
+
168
+ const petaUrl = computed(() => {
169
+ const { lat, lng } = meta.value
170
+ return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`
171
+ })
172
+ </script>
173
+
174
+ <template>
175
+ <div class="space-y-1.5" :data-msg-type="tipe">
176
+ <!-- ── Lampiran GAMBAR ───────────────────────────────────────────────────────────────── -->
177
+ <template v-if="tipe === 'image'">
178
+ <div v-if="terhapus" class="flex items-center gap-2 text-xs italic opacity-80" data-attachment-gone>
179
+ <ImageOff class="w-4 h-4 shrink-0" />
180
+ <span>{{ t('chat.attachmentGone', { name: meta.name }) }}</span>
181
+ </div>
182
+ <button v-else type="button" class="block" data-image-open :aria-label="meta.name || t('chat.openImage')" @click="lightbox = true">
183
+ <!-- ⚠️ Klik-kanan sengaja dimatikan. Ini **penghalang, bukan
184
+ perlindungan**: alamat berkasnya tetap ada di DOM dan tetap bisa diambil siapa pun yang
185
+ membuka devtools. Gunanya mencegah penyimpanan TIDAK SENGAJA lewat menu browser yang
186
+ melewati jalur unduh kita (& namanya jadi nama storage) — bukan mengamankan gambar.
187
+ `-webkit-touch-callout` mematikan padanannya di iOS (tekan-lama). -->
188
+ <img
189
+ :src="msg.file_url"
190
+ :alt="meta.name || ''"
191
+ class="rounded-lg max-w-full max-h-64 object-cover select-none [-webkit-touch-callout:none]"
192
+ loading="lazy"
193
+ draggable="false"
194
+ @contextmenu.prevent
195
+ />
196
+ </button>
197
+ </template>
198
+
199
+ <!-- ── Lampiran BERKAS ───────────────────────────────────────────────────────────────── -->
200
+ <template v-else-if="tipe === 'file'">
201
+ <div v-if="terhapus" class="flex items-center gap-2 text-xs italic opacity-80" data-attachment-gone>
202
+ <FileText class="w-4 h-4 shrink-0" />
203
+ <span>{{ t('chat.attachmentGone', { name: meta.name }) }}</span>
204
+ </div>
205
+ <!-- ⚠️ Menunjuk rute UNDUH, bukan `file_url`: `Content-Disposition: attachment` membuat klik
206
+ langsung menyimpan berkas dengan nama ASLI, tanpa membuka tab & tanpa memperlihatkan nama
207
+ simpan yang opak. `target="_blank"` sengaja DILEPAS — dengan header itu, tab baru cuma
208
+ berkedip lalu tertutup sendiri. -->
209
+ <a
210
+ v-else
211
+ :href="unduhUrl || msg.file_url"
212
+ rel="noopener"
213
+ class="flex items-center gap-2.5 rounded-lg px-2.5 py-2 bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20"
214
+ data-file-bubble
215
+ >
216
+ <FileText class="w-5 h-5 shrink-0 opacity-70" />
217
+ <span class="min-w-0 flex-1">
218
+ <span class="block truncate text-sm">{{ meta.name }}</span>
219
+ <span class="block text-[11px] opacity-70">{{ ukuran(meta.size) }}</span>
220
+ </span>
221
+ <Download class="w-4 h-4 shrink-0 opacity-70" />
222
+ </a>
223
+ </template>
224
+
225
+ <!-- ── LOKASI ────────────────────────────────────────────────────────────────────────── -->
226
+ <template v-else-if="tipe === 'location'">
227
+ <!-- ⚠️ `isolation: isolate` WAJIB. Leaflet memasang z-index internal sampai ~1000 pada
228
+ pane & kontrolnya; tanpa stacking context sendiri, angka itu bocor ke konteks panel dan
229
+ MENUTUPI menu lampir di komposer (menunya tergambar di balik peta). Isolasi menahan seluruh angka itu di dalam kotak petanya sendiri. -->
230
+ <!-- Kartu peta sengaja NETRAL (abu), tidak ikut warna bubble: peta itu sendiri penuh warna,
231
+ dan menaruhnya di atas biru membuat baris koordinat menyatu dengan latar sampai nyaris
232
+ tak terbaca. Kartu abu memberi peta bingkainya sendiri. -->
233
+ <div
234
+ class="rounded-lg overflow-hidden isolate relative z-0 bg-white dark:bg-gray-800 border border-black/5 dark:border-white/10"
235
+ data-location-bubble
236
+ >
237
+ <div ref="petaEl" class="h-36 w-56 bg-gray-100 dark:bg-gray-700" />
238
+ <a
239
+ :href="petaUrl"
240
+ target="_blank"
241
+ rel="noopener"
242
+ class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-gray-600 dark:text-gray-300 hover:underline"
243
+ >
244
+ <MapPin class="w-3.5 h-3.5 shrink-0 text-gray-400" />
245
+ <span class="truncate">{{ meta.label || `${meta.lat}, ${meta.lng}` }}</span>
246
+ </a>
247
+ </div>
248
+ </template>
249
+
250
+ <!-- ── KARTU ENTITAS (lintas-app) ────────────────────────────────────────────────────── -->
251
+ <template v-else-if="tipe === 'entity'">
252
+ <!-- Kartu = SNAPSHOT teks. Kliknya menempuh gerbang modul/seat normal di app tujuan, jadi
253
+ tak ada yang bocor: yang tak berhak melihat isinya tetap tak bisa membukanya. -->
254
+ <button
255
+ type="button"
256
+ class="w-full text-left rounded-lg px-3 py-2 bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20"
257
+ data-entity-card
258
+ @click="openTarget(meta)"
259
+ >
260
+ <span class="flex items-start gap-2">
261
+ <span class="min-w-0 flex-1">
262
+ <span class="block text-sm font-medium truncate">{{ meta.title }}</span>
263
+ <span v-if="meta.subtitle" class="block text-xs opacity-70 truncate">{{ meta.subtitle }}</span>
264
+ <span v-if="meta.amount" class="block text-xs font-semibold mt-0.5">{{ meta.amount }}</span>
265
+ </span>
266
+ <ExternalLink v-if="meta.target_app && meta.target_path" class="w-4 h-4 shrink-0 opacity-60" />
267
+ </span>
268
+ </button>
269
+ </template>
270
+
271
+ <!-- Caption / teks biasa — SELALU lewat sanitizer, tak pernah `v-html` mentah. -->
272
+ <p v-if="msg.body" class="whitespace-pre-wrap chat-markup" v-html="bodyHtml" />
273
+
274
+ <!-- Lightbox: ditempel ke <body> dgn z di ATAS widget chat (widget z-46, `AppModal` z-50) —
275
+ kalau tidak, ia tergambar DI BALIK panel yang memunculkannya. -->
276
+ <Teleport v-if="lightbox" to="body">
277
+ <div class="fixed inset-0 z-[80] bg-black/80 flex flex-col" data-lightbox @click.self="tutupLightbox">
278
+ <div class="flex items-center justify-between gap-3 px-4 py-3 text-white/90 shrink-0">
279
+ <span class="text-sm truncate">{{ meta.name || '' }}</span>
280
+ <div class="flex items-center gap-2 shrink-0">
281
+ <!-- `download` memakai nama ASLI dari snapshot meta, bukan nama acak di storage. -->
282
+ <button
283
+ type="button"
284
+ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm bg-white/15 hover:bg-white/25 disabled:opacity-60"
285
+ :disabled="mengunduh"
286
+ data-lightbox-download
287
+ @click="unduhGambar"
288
+ >
289
+ <Download class="w-4 h-4" />{{ t('chat.download') }}
290
+ </button>
291
+ <button
292
+ type="button"
293
+ class="w-9 h-9 rounded-lg bg-white/15 hover:bg-white/25 flex items-center justify-center"
294
+ :aria-label="t('common.close')"
295
+ data-lightbox-close
296
+ @click="tutupLightbox"
297
+ ><X class="w-4 h-4" /></button>
298
+ </div>
299
+ </div>
300
+ <div class="flex-1 min-h-0 flex items-center justify-center p-4" @click.self="tutupLightbox">
301
+ <img
302
+ :src="msg.file_url"
303
+ :alt="meta.name || ''"
304
+ class="max-w-full max-h-full object-contain rounded select-none [-webkit-touch-callout:none]"
305
+ draggable="false"
306
+ data-lightbox-image
307
+ @contextmenu.prevent
308
+ />
309
+ </div>
310
+ </div>
311
+ </Teleport>
312
+ </div>
313
+ </template>
314
+
315
+ <style scoped>
316
+ .chat-markup :deep(code) {
317
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
318
+ font-size: 0.9em;
319
+ padding: 0 0.25em;
320
+ border-radius: 3px;
321
+ background: rgba(0, 0, 0, 0.08);
322
+ }
323
+ :global(.dark) .chat-markup :deep(code) { background: rgba(255, 255, 255, 0.15); }
324
+ </style>
@@ -0,0 +1,249 @@
1
+ <script setup>
2
+ // Isi popup chat saat view = 'list': header + daftar percakapan + form "Mulai Percakapan".
3
+ import { ref, computed } from 'vue'
4
+ import { Plus, ArrowLeft, Loader2, MessageSquareText, Send } from '@lucide/vue'
5
+ import { useI18n } from 'vue-i18n'
6
+ import { useChat } from '@initsolusidigital/worksuite-core/composables/useChat.js'
7
+ import { stripChatMarkup } from '@initsolusidigital/worksuite-core/utils/chatMarkup.js'
8
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
9
+ import { usersApi } from '@initsolusidigital/worksuite-core/api/endpoints/users.js'
10
+ import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
11
+
12
+ const { t, locale } = useI18n()
13
+ const authStore = useAuthStore()
14
+ const {
15
+ conversations, loadingList, hasMoreConversations,
16
+ fetchConversations, fetchMoreConversations, openConversation, startConversation,
17
+ otherUser, displayName,
18
+ } = useChat()
19
+
20
+ // ── Form "Mulai Percakapan" ────────────────────────────────────────────────────
21
+ const showPicker = ref(false)
22
+ const userOptions = ref([])
23
+ const loadingUsers = ref(false)
24
+ const selectedUserId = ref(null)
25
+ const firstMessage = ref('')
26
+ const starting = ref(false)
27
+
28
+ async function openPicker() {
29
+ showPicker.value = true
30
+ selectedUserId.value = null
31
+ firstMessage.value = ''
32
+ if (userOptions.value.length) return
33
+ loadingUsers.value = true
34
+ try {
35
+ // Company sendiri otomatis (self-scoped) — buang diri sendiri dari opsi
36
+ const { data } = await usersApi.getList()
37
+ const rows = data.data?.data ?? data.data ?? []
38
+ userOptions.value = rows
39
+ .filter((u) => u.id !== authStore.user?.id)
40
+ .map((u) => ({
41
+ value: u.id,
42
+ label: [u.first_name, u.last_name].filter(Boolean).join(' '),
43
+ // /get_users belum kirim avatar — dipasang best-effort (fallback inisial di bawah)
44
+ // supaya otomatis kepakai kalau field ini ditambahkan backend nanti, tanpa perlu ubah UI lagi
45
+ avatar: u.avatar ?? null,
46
+ }))
47
+ // Urut A→Z — daftar bisa ratusan user; backend mengirim urut insert
48
+ .sort((a, b) => a.label.localeCompare(b.label, 'id', { sensitivity: 'base' }))
49
+ } catch {
50
+ userOptions.value = []
51
+ } finally {
52
+ loadingUsers.value = false
53
+ }
54
+ }
55
+
56
+ async function submitStart() {
57
+ if (!selectedUserId.value || starting.value) return
58
+ starting.value = true
59
+ try {
60
+ await startConversation(selectedUserId.value, firstMessage.value.trim() || undefined)
61
+ showPicker.value = false
62
+ } finally {
63
+ starting.value = false
64
+ }
65
+ }
66
+
67
+ // ── Formatting ──────────────────────────────────────────────────────────────────
68
+ /**
69
+ * Pratinjau pesan terakhir.
70
+ *
71
+ * ⚠️ Penanda styling DIBUANG di sini, bukan dirender: pratinjau satu baris tak punya tempat untuk
72
+ * tebal/miring, dan membiarkannya mentah membuat daftar dipenuhi `*bintang*`. Dekorasi milik
73
+ * bubble, teks polos milik pratinjau.
74
+ *
75
+ * ⚠️ Pesan LAMPIRAN sering tanpa `body` (caption opsional) — tanpa cabang ini barisnya kosong dan
76
+ * percakapan tampak seperti tak berisi apa-apa. Kalimatnya dirakit dari `type`, bukan dari body
77
+ * backend.
78
+ */
79
+ function snippet(conv) {
80
+ const last = conv.last_message
81
+ const body = String(last?.body ?? '')
82
+ const polos = stripChatMarkup(body)
83
+ if (polos.trim()) return polos.length > 80 ? polos.slice(0, 80) + '…' : polos
84
+ const meta = last?.meta ?? {}
85
+ return {
86
+ image: t('chat.preview_image'),
87
+ file: t('chat.preview_file', { name: meta.name || '' }),
88
+ location: t('chat.preview_location'),
89
+ entity: t('chat.preview_entity', { title: meta.title || '' }),
90
+ }[last?.type] ?? ''
91
+ }
92
+
93
+ function relativeTime(iso) {
94
+ if (!iso) return ''
95
+ const then = new Date(iso).getTime()
96
+ const diff = Math.max(0, Date.now() - then)
97
+ const min = Math.floor(diff / 60000)
98
+ if (min < 1) return t('chat.time.now')
99
+ if (min < 60) return t('chat.time.minutes', { n: min })
100
+ const hr = Math.floor(min / 60)
101
+ if (hr < 24) return t('chat.time.hours', { n: hr })
102
+ const day = Math.floor(hr / 24)
103
+ if (day < 7) return t('chat.time.days', { n: day })
104
+ return new Date(iso).toLocaleDateString(locale.value === 'id' ? 'id-ID' : 'en-US', {
105
+ day: 'numeric',
106
+ month: 'short',
107
+ })
108
+ }
109
+
110
+ function initials(user) {
111
+ const name = displayName(user)
112
+ return name.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase()).filter(Boolean).join('') || '?'
113
+ }
114
+
115
+ // Sama seperti initials() tapi dari string label langsung — dipakai opsi picker "Mulai
116
+ // Percakapan" (userOptions cuma {value, label, avatar}, bukan objek user dari useChat)
117
+ function initialsFromLabel(label) {
118
+ return (label || '').split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase()).filter(Boolean).join('') || '?'
119
+ }
120
+
121
+ const headerTitle = computed(() => (showPicker.value ? t('chat.newConversation') : t('chat.title')))
122
+ </script>
123
+
124
+ <template>
125
+ <div class="flex flex-col h-full">
126
+ <!-- Header -->
127
+ <div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex items-center gap-2 bg-gray-50/60 dark:bg-gray-700/40 shrink-0">
128
+ <button
129
+ v-if="showPicker"
130
+ @click="showPicker = false"
131
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
132
+ :aria-label="t('common.back')"
133
+ >
134
+ <ArrowLeft class="w-4 h-4" />
135
+ </button>
136
+ <h3 class="text-sm font-semibold text-gray-800 dark:text-white flex-1 truncate">{{ headerTitle }}</h3>
137
+ <button
138
+ v-if="!showPicker"
139
+ @click="openPicker"
140
+ class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
141
+ >
142
+ <Plus class="w-3.5 h-3.5" /> {{ t('chat.start') }}
143
+ </button>
144
+ </div>
145
+
146
+ <!-- Form mulai percakapan -->
147
+ <div v-if="showPicker" class="flex-1 overflow-y-auto scrollbar-rounded p-4 flex flex-col gap-3">
148
+ <div>
149
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">{{ t('chat.recipient') }}</label>
150
+ <div v-if="loadingUsers" class="flex items-center gap-2 text-xs text-gray-400 py-2">
151
+ <Loader2 class="w-4 h-4 animate-spin" /> {{ t('common.loading') }}
152
+ </div>
153
+ <AppCombobox
154
+ v-else
155
+ v-model="selectedUserId"
156
+ :options="userOptions"
157
+ :aria-label="t('chat.recipient')"
158
+ :placeholder="t('chat.recipientPlaceholder')"
159
+ option-label="label"
160
+ >
161
+ <template #option-icon="{ option }">
162
+ <span class="inline-flex w-6 h-6 rounded-full overflow-hidden shrink-0 bg-blue-100 dark:bg-blue-900/40 items-center justify-center">
163
+ <img v-if="option.avatar" :src="option.avatar" class="w-full h-full object-cover" alt="" />
164
+ <span v-else class="text-[10px] font-semibold text-blue-700 dark:text-blue-300">{{ initialsFromLabel(option.label) }}</span>
165
+ </span>
166
+ </template>
167
+ </AppCombobox>
168
+ </div>
169
+ <div>
170
+ <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">{{ t('chat.firstMessage') }}</label>
171
+ <textarea
172
+ v-model="firstMessage"
173
+ rows="3"
174
+ :placeholder="t('chat.placeholder')"
175
+ maxlength="5000"
176
+ class="w-full text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none scrollbar-rounded"
177
+ />
178
+ </div>
179
+ <button
180
+ @click="submitStart"
181
+ :disabled="!selectedUserId || starting"
182
+ class="inline-flex items-center justify-center gap-2 w-full py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
183
+ >
184
+ <Loader2 v-if="starting" class="w-4 h-4 animate-spin" />
185
+ <Send v-else class="w-4 h-4" />
186
+ {{ t('chat.startButton') }}
187
+ </button>
188
+ </div>
189
+
190
+ <!-- Daftar percakapan -->
191
+ <div v-else class="flex-1 overflow-y-auto scrollbar-rounded">
192
+ <div v-if="loadingList && conversations.length === 0" class="flex items-center justify-center h-full py-12">
193
+ <Loader2 class="w-5 h-5 text-gray-300 dark:text-gray-600 animate-spin" />
194
+ </div>
195
+
196
+ <div v-else-if="conversations.length === 0" class="flex flex-col items-center justify-center h-full py-12 px-6 text-center">
197
+ <MessageSquareText class="w-12 h-12 text-gray-200 dark:text-gray-700 mb-3" :stroke-width="1.5" />
198
+ <p class="text-sm text-gray-400 dark:text-gray-500">{{ t('chat.empty') }}</p>
199
+ <button @click="openPicker" class="mt-3 text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline">
200
+ {{ t('chat.start') }}
201
+ </button>
202
+ </div>
203
+
204
+ <template v-else>
205
+ <button
206
+ v-for="conv in conversations"
207
+ :key="conv.id"
208
+ @click="openConversation(conv.id)"
209
+ class="w-full text-left px-3 py-3 border-b border-gray-50 dark:border-gray-800/60 flex items-center gap-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
210
+ >
211
+ <!-- Avatar lawan bicara -->
212
+ <div class="shrink-0 w-10 h-10 rounded-full overflow-hidden bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center">
213
+ <img
214
+ v-if="otherUser(conv)?.avatar"
215
+ :src="otherUser(conv).avatar"
216
+ class="w-full h-full object-cover"
217
+ alt=""
218
+ />
219
+ <span v-else class="text-xs font-semibold text-blue-700 dark:text-blue-300">{{ initials(otherUser(conv)) }}</span>
220
+ </div>
221
+ <!-- Nama + cuplikan -->
222
+ <div class="flex-1 min-w-0">
223
+ <div class="flex items-center justify-between gap-2">
224
+ <p class="text-sm font-semibold text-gray-800 dark:text-white truncate">{{ displayName(otherUser(conv)) || t('chat.unknownUser') }}</p>
225
+ <span class="text-[11px] text-gray-300 dark:text-gray-600 shrink-0">{{ relativeTime(conv.last_message_at) }}</span>
226
+ </div>
227
+ <div class="flex items-center justify-between gap-2 mt-0.5">
228
+ <p class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ snippet(conv) || '—' }}</p>
229
+ <span
230
+ v-if="conv.unread_count > 0"
231
+ class="shrink-0 min-w-[18px] h-[18px] px-1 rounded-full bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center leading-none"
232
+ >{{ conv.unread_count > 9 ? '9+' : conv.unread_count }}</span>
233
+ </div>
234
+ </div>
235
+ </button>
236
+
237
+ <div v-if="hasMoreConversations" class="p-3 text-center">
238
+ <button
239
+ @click="fetchMoreConversations"
240
+ :disabled="loadingList"
241
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50"
242
+ >
243
+ {{ loadingList ? t('common.loading') : t('chat.loadMore') }}
244
+ </button>
245
+ </div>
246
+ </template>
247
+ </div>
248
+ </div>
249
+ </template>