@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,315 @@
1
+ <script setup>
2
+ // Isi popup chat saat view = 'conversation': header lawan bicara + riwayat pesan + quick reply.
3
+ import { ref, computed, watch, nextTick, onMounted } from 'vue'
4
+ import { ArrowLeft, Send, Loader2, ExternalLink, Check, CheckCheck, Paperclip, FileUp, MapPin } from '@lucide/vue'
5
+ import AppChatBubble from './AppChatBubble.vue'
6
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
7
+ import { useI18n } from 'vue-i18n'
8
+ import { useChat, hasGeolocation } from '@initsolusidigital/worksuite-core/composables/useChat.js'
9
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
10
+ import { appUrl } from '@initsolusidigital/worksuite-core/utils/appUrl.js'
11
+
12
+ const { t, locale } = useI18n()
13
+ const authStore = useAuthStore()
14
+ const {
15
+ activeConversation, messages, loadingMessages, hasMoreMessages, sending,
16
+ backToList, fetchMoreMessages, sendMessage, sendAttachment, sendLocation, otherUser, displayName,
17
+ } = useChat()
18
+
19
+ const draft = ref('')
20
+ const scrollArea = ref(null)
21
+
22
+ // URL app Chat — tombol "Buka Percakapan" hanya muncul bila alamatnya diketahui.
23
+ // `appUrl()` memakai env saat dev per-port dan menurunkan `${origin}/chat` saat halaman disajikan
24
+ // di origin tunggal (proxy/produksi), sehingga satu build benar di kedua mode tanpa rebuild.
25
+ const chatAppUrl = import.meta.env.VITE_CHAT_APP_URL
26
+ const openConversationUrl = computed(() =>
27
+ activeConversation.value
28
+ ? appUrl('chat', chatAppUrl, `/chats/${activeConversation.value.id}`)
29
+ : null,
30
+ )
31
+
32
+ const partner = computed(() => (activeConversation.value ? otherUser(activeConversation.value) : null))
33
+
34
+ // Kapan lawan bicara TERAKHIR membaca percakapan ini (dari backend, per participant).
35
+ // Dipakai status centang terbaca. Field ini butuh dukungan backend (`participants[].last_read_at`);
36
+ // selama backend belum kirim → null → pesan sendiri berhenti di centang "terkirim" (bukan biru).
37
+ const partnerLastReadAt = computed(() => {
38
+ const conv = activeConversation.value
39
+ if (!conv) return null
40
+ const p = (conv.participants ?? []).find((pt) => pt.user?.id !== authStore.user?.id)
41
+ return p?.last_read_at ?? null
42
+ })
43
+
44
+ function isMine(msg) {
45
+ return msg.sender_id && msg.sender_id === authStore.user?.id
46
+ }
47
+
48
+ // Pesan kita sudah dibaca lawan bila waktu kirimnya <= waktu baca terakhir lawan.
49
+ function isRead(msg) {
50
+ if (!partnerLastReadAt.value) return false
51
+ return new Date(msg.created_at).getTime() <= new Date(partnerLastReadAt.value).getTime()
52
+ }
53
+
54
+ // Tampilkan label "Dibaca" hanya bila pesan TERAKHIR di thread milik kita & sudah dibaca
55
+ // (kalau lawan balas setelahnya, balasannya sendiri sudah menandakan dibaca → label tak perlu).
56
+ const showReadLabel = computed(() => {
57
+ const last = messages.value[messages.value.length - 1]
58
+ return !!last && isMine(last) && isRead(last)
59
+ })
60
+ function isSystem(msg) {
61
+ return !msg.sender_id || msg.type === 'system'
62
+ }
63
+
64
+ function msgTime(iso) {
65
+ return new Date(iso).toLocaleTimeString(locale.value === 'id' ? 'id-ID' : 'en-US', {
66
+ hour: '2-digit',
67
+ minute: '2-digit',
68
+ })
69
+ }
70
+
71
+ function initials(user) {
72
+ const name = displayName(user)
73
+ return name.split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase()).filter(Boolean).join('') || '?'
74
+ }
75
+
76
+ async function submit() {
77
+ const text = draft.value.trim()
78
+ if (!text || sending.value) return
79
+ draft.value = ''
80
+ await sendMessage(text)
81
+ }
82
+
83
+ // ── Lampiran ────────────────────────────────────────────────────────────────────────────────
84
+ const fileEl = ref(null)
85
+ const galatLampiran = ref('')
86
+ const mengunggah = ref(false)
87
+
88
+ async function onPilihBerkas(e) {
89
+ const file = e.target.files?.[0]
90
+ e.target.value = '' // supaya memilih berkas SAMA dua kali tetap memicu
91
+ if (!file || mengunggah.value) return
92
+ galatLampiran.value = ''
93
+ mengunggah.value = true
94
+ try {
95
+ // Caption diambil dari kotak ketik: satu tindakan, bukan dua langkah terpisah bagi pemakai.
96
+ const caption = draft.value.trim()
97
+ draft.value = ''
98
+ await sendAttachment(file, caption)
99
+ } catch (err) {
100
+ const info = normalizeApiError(err)
101
+ // 413 & 422 punya sebab yang bisa dijelaskan; sisanya jangan ditebak. Ditampilkan INLINE di
102
+ // atas kotak ketik, bukan toast — toast menutupi percakapan yang sedang dibaca.
103
+ galatLampiran.value = info.status === 413
104
+ ? t('chat.attachTooLarge')
105
+ : info.status === 422
106
+ ? t('chat.attachRejected')
107
+ : t('common.serverError')
108
+ } finally {
109
+ mengunggah.value = false
110
+ }
111
+ }
112
+
113
+ const menuLampir = ref(false)
114
+ const geoAda = hasGeolocation()
115
+
116
+ async function kirimLokasi() {
117
+ menuLampir.value = false
118
+ if (mengunggah.value) return
119
+ galatLampiran.value = ''
120
+ mengunggah.value = true
121
+ try {
122
+ const label = draft.value.trim()
123
+ draft.value = ''
124
+ await sendLocation(label)
125
+ } catch (err) {
126
+ // Satu-satunya kegagalan yang perlu dijelaskan: user menolak izin lokasi. Sisanya generik.
127
+ galatLampiran.value = err?.message === 'geolocation_denied'
128
+ ? t('chat.locationDenied')
129
+ : t('common.serverError')
130
+ } finally {
131
+ mengunggah.value = false
132
+ }
133
+ }
134
+
135
+ // Enter kirim, Shift+Enter baris baru
136
+ function onKeydown(e) {
137
+ if (e.key === 'Enter' && !e.shiftKey) {
138
+ e.preventDefault()
139
+ submit()
140
+ }
141
+ }
142
+
143
+ function scrollToBottom() {
144
+ nextTick(() => {
145
+ if (scrollArea.value) scrollArea.value.scrollTop = scrollArea.value.scrollHeight
146
+ })
147
+ }
148
+
149
+ // Auto-scroll ke bawah saat jumlah pesan bertambah (pesan baru masuk / kirim)
150
+ watch(() => messages.value.length, scrollToBottom)
151
+ onMounted(scrollToBottom)
152
+ </script>
153
+
154
+ <template>
155
+ <div class="flex flex-col h-full">
156
+ <!-- Header lawan bicara -->
157
+ <div class="px-3 py-2.5 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">
158
+ <button
159
+ @click="backToList"
160
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors shrink-0"
161
+ :aria-label="t('common.back')"
162
+ >
163
+ <ArrowLeft class="w-4 h-4" />
164
+ </button>
165
+ <div class="shrink-0 w-8 h-8 rounded-full overflow-hidden bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center">
166
+ <img v-if="partner?.avatar" :src="partner.avatar" class="w-full h-full object-cover" alt="" />
167
+ <span v-else class="text-[10px] font-semibold text-blue-700 dark:text-blue-300">{{ initials(partner) }}</span>
168
+ </div>
169
+ <p class="text-sm font-semibold text-gray-800 dark:text-white flex-1 truncate">
170
+ {{ displayName(partner) || t('chat.unknownUser') }}
171
+ </p>
172
+ <a
173
+ v-if="openConversationUrl"
174
+ :href="openConversationUrl"
175
+ target="_blank"
176
+ rel="noopener"
177
+ :title="t('chat.openConversation')"
178
+ class="shrink-0 inline-flex items-center gap-1 text-[11px] text-blue-600 dark:text-blue-400 hover:underline"
179
+ >
180
+ <ExternalLink class="w-3.5 h-3.5" />
181
+ <span class="hidden sm:inline">{{ t('chat.openConversation') }}</span>
182
+ </a>
183
+ </div>
184
+
185
+ <!-- Riwayat pesan -->
186
+ <div ref="scrollArea" class="flex-1 overflow-y-auto scrollbar-rounded px-3 py-3 space-y-2 bg-gray-50/40 dark:bg-gray-900/20">
187
+ <!-- Muat pesan lebih lama -->
188
+ <div v-if="hasMoreMessages" class="text-center pb-1">
189
+ <button
190
+ @click="fetchMoreMessages"
191
+ :disabled="loadingMessages"
192
+ class="text-[11px] text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50"
193
+ >
194
+ {{ loadingMessages ? t('common.loading') : t('chat.loadOlder') }}
195
+ </button>
196
+ </div>
197
+
198
+ <div v-if="loadingMessages && messages.length === 0" class="flex items-center justify-center py-12">
199
+ <Loader2 class="w-5 h-5 text-gray-300 dark:text-gray-600 animate-spin" />
200
+ </div>
201
+
202
+ <template v-for="msg in messages" :key="msg.id">
203
+ <!-- Pesan sistem: italic tengah -->
204
+ <div v-if="isSystem(msg)" class="text-center">
205
+ <span class="inline-block text-[11px] italic text-gray-400 dark:text-gray-500 px-2 py-0.5">{{ msg.body }}</span>
206
+ </div>
207
+ <!-- Pesan biasa -->
208
+ <div v-else class="flex" :class="isMine(msg) ? 'justify-end' : 'justify-start'">
209
+ <!-- Pesan MEDIA (gambar/peta) memakai padding rapat: isinya sudah punya bingkai sendiri,
210
+ jadi padding penuh menyisakan pita warna tebal di sekelilingnya — terlihat seperti
211
+ jarak yang salah, bukan seperti bubble. Pesan teks tetap lapang seperti semula. -->
212
+ <div
213
+ class="max-w-[75%] rounded-2xl text-sm break-words"
214
+ :class="[
215
+ ['image', 'location'].includes(msg.type) && !msg.body ? 'p-1' : 'px-3 py-2',
216
+ isMine(msg)
217
+ ? 'bg-blue-600 text-white rounded-br-sm'
218
+ : 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 border border-gray-100 dark:border-gray-600 rounded-bl-sm',
219
+ ]"
220
+ >
221
+ <AppChatBubble :msg="msg" :mine="isMine(msg)" />
222
+ <!-- Waktu + status centang (terkirim/terbaca) — centang hanya utk pesan sendiri -->
223
+ <div class="flex items-center justify-end gap-1 mt-0.5 text-[10px]" :class="isMine(msg) ? 'text-blue-100/80' : 'text-gray-400 dark:text-gray-500'">
224
+ <span>{{ msgTime(msg.created_at) }}</span>
225
+ <template v-if="isMine(msg)">
226
+ <!-- Read = centang ganda putih PENUH (kontras maksimal di bubble biru);
227
+ Sent = centang tunggal redup. Beda jumlah + kecerahan = jelas terbaca. -->
228
+ <CheckCheck v-if="isRead(msg)" class="w-3.5 h-3.5 text-white shrink-0" :title="t('chat.status.read')" />
229
+ <Check v-else class="w-3.5 h-3.5 text-blue-200/60 shrink-0" :title="t('chat.status.sent')" />
230
+ </template>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </template>
235
+
236
+ <!-- Label "Dibaca {jam}" di luar bubble (ala iMessage) — hanya bila pesan TERAKHIR di thread
237
+ adalah milik kita & sudah dibaca. Konfirmasi read yang tak ambigu, tanpa mengandalkan
238
+ warna centang di bubble biru. -->
239
+ <div v-if="showReadLabel" class="flex justify-end pr-1 -mt-1">
240
+ <span class="text-[10px] text-gray-400 dark:text-gray-500">
241
+ {{ t('chat.status.readAt', { time: msgTime(partnerLastReadAt) }) }}
242
+ </span>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Quick reply -->
247
+ <div class="border-t border-gray-100 dark:border-gray-700 shrink-0">
248
+ <p v-if="galatLampiran" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400" data-attach-error>
249
+ {{ galatLampiran }}
250
+ </p>
251
+ <div class="p-2 flex items-end gap-2 relative">
252
+ <input ref="fileEl" type="file" class="hidden" data-attach-input @change="onPilihBerkas" />
253
+ <!-- Menu lampir: berkas atau lokasi. Ditutup saat salah satu dipilih & saat klik di luar. -->
254
+ <div v-if="menuLampir" class="fixed inset-0 z-[58]" @click="menuLampir = false" />
255
+ <div
256
+ v-if="menuLampir"
257
+ class="absolute bottom-14 left-2 z-[59] w-56 rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-lg py-1"
258
+ data-attach-menu
259
+ >
260
+ <button
261
+ type="button"
262
+ class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
263
+ data-attach-file
264
+ @click="menuLampir = false; fileEl?.click()"
265
+ ><FileUp class="w-4 h-4 opacity-70" />{{ t('chat.attachFile') }}</button>
266
+ <button
267
+ type="button"
268
+ class="w-full flex items-start gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
269
+ data-attach-location
270
+ @click="kirimLokasi"
271
+ >
272
+ <MapPin class="w-4 h-4 mt-0.5 opacity-70 shrink-0" />
273
+ <span class="text-left">
274
+ {{ t('chat.attachLocation') }}
275
+ <!-- Dikatakan APA ADANYA saat geolokasi tak tersedia: yang terkirim koordinat contoh,
276
+ bukan posisi user. Menyembunyikannya membuat orang mengira lokasinya benar. -->
277
+ <span v-if="!geoAda" class="block text-[11px] text-amber-600 dark:text-amber-400">
278
+ {{ t('chat.locationDemo') }}
279
+ </span>
280
+ </span>
281
+ </button>
282
+ </div>
283
+ <button
284
+ type="button"
285
+ class="shrink-0 w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-300 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
286
+ :disabled="mengunggah"
287
+ :aria-label="t('chat.attach')"
288
+ :title="t('chat.attach')"
289
+ data-attach-btn
290
+ @click="menuLampir = !menuLampir"
291
+ >
292
+ <Loader2 v-if="mengunggah" class="w-4 h-4 animate-spin" />
293
+ <Paperclip v-else class="w-4 h-4" />
294
+ </button>
295
+ <textarea
296
+ v-model="draft"
297
+ rows="1"
298
+ :placeholder="t('chat.placeholder')"
299
+ maxlength="5000"
300
+ @keydown="onKeydown"
301
+ class="flex-1 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 max-h-24 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 scrollbar-rounded"
302
+ />
303
+ <button
304
+ @click="submit"
305
+ :disabled="!draft.trim() || sending"
306
+ class="shrink-0 w-10 h-10 rounded-lg bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
307
+ :aria-label="t('chat.send')"
308
+ >
309
+ <Loader2 v-if="sending" class="w-4 h-4 animate-spin" />
310
+ <Send v-else class="w-4 h-4" />
311
+ </button>
312
+ </div>
313
+ </div>
314
+ </div>
315
+ </template>
@@ -0,0 +1,126 @@
1
+ <script setup>
2
+ // ── Widget Chat Melayang ──────────────────────────────────────────────────────
3
+ // Tombol melayang kanan-bawah di semua halaman + popup "Pesan Terbaru" berisi daftar
4
+ // percakapan / panel percakapan ringkas. Real-time via Laravel Echo + Reverb (lihat useChat).
5
+ //
6
+ // Gate permission modul `chats` dilakukan di AppLayout (widget tidak dirender bila tak boleh),
7
+ // tapi init/teardown real-time & badge ditangani di sini karena widget = titik hidup chat.
8
+ import { onMounted, onBeforeUnmount } from 'vue'
9
+ import { MessageSquareText, X } from '@lucide/vue'
10
+ import { useI18n } from 'vue-i18n'
11
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
12
+ import { useChat } from '@initsolusidigital/worksuite-core/composables/useChat.js'
13
+ import AppChatList from './AppChatList.vue'
14
+ import AppChatPanel from './AppChatPanel.vue'
15
+
16
+ const { t } = useI18n()
17
+ const authStore = useAuthStore()
18
+ const {
19
+ isOpen, view, unreadTotal,
20
+ toggleWidget, closeWidget,
21
+ fetchUnreadTotal, initRealtime, teardownRealtime,
22
+ } = useChat()
23
+
24
+ onMounted(() => {
25
+ // Badge awal + langganan real-time channel user (sekali per sesi login)
26
+ fetchUnreadTotal()
27
+ initRealtime(authStore.user?.id)
28
+ })
29
+
30
+ onBeforeUnmount(() => {
31
+ // Logout / keluar area authenticated → putus socket supaya sesi berikutnya bersih
32
+ teardownRealtime()
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <!-- Backdrop transparan penutup saat klik di luar -->
38
+ <div v-if="isOpen" class="fixed inset-0 z-[44]" @click="closeWidget()" />
39
+
40
+ <!-- Popup percakapan. Tombol melayang ditumpuk satu kolom di kanan (Asisten AI di atas Chat),
41
+ jadi panel dibuka DI SEBELAH KIRI kolom itu pada layar ≥ sm — dibuka di atas tombol chat,
42
+ ia menimpa tombol Asisten AI. Di layar sempit tak ada ruang di kiri, jadi panel duduk di
43
+ ATAS seluruh tumpukan (24 + 56 + 12 + 48 + 12 px = 9.5rem). Semua posisi ikut `--fab-lift`
44
+ (AppLayout) supaya tumpukan & panelnya naik bersama saat footer terlihat.
45
+ ⚠️ Batas tinggi = tinggi layar − jarak bawah − ruang navbar melayang (berakhir ~88px) − jarak.
46
+ Batas yang terlalu longgar membuat puncak panel masuk DI BALIK navbar pada layar pendek,
47
+ sehingga kepalanya tak bisa dilihat maupun disentuh. -->
48
+ <Transition name="chat-pop">
49
+ <div
50
+ v-if="isOpen"
51
+ class="fixed bottom-[calc(9.5rem+var(--fab-lift,0px))] right-4 sm:bottom-[calc(1.5rem+var(--fab-lift,0px))] sm:right-24 z-[46] w-[calc(100vw-2rem)] sm:w-96 max-w-96
52
+ bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700
53
+ overflow-hidden flex flex-col h-[520px] max-h-[calc(100vh-16rem-var(--fab-lift,0px))] sm:max-h-[calc(100vh-8rem-var(--fab-lift,0px))]"
54
+ >
55
+ <AppChatPanel v-if="view === 'conversation'" />
56
+ <AppChatList v-else />
57
+ </div>
58
+ </Transition>
59
+
60
+ <!-- Tombol melayang -->
61
+ <button
62
+ type="button"
63
+ :title="t('chat.button')"
64
+ :aria-label="t('chat.button')"
65
+ @click="toggleWidget()"
66
+ class="fixed bottom-[calc(1.5rem+var(--fab-lift,0px))] right-4 sm:right-6 z-[46] w-14 h-14 rounded-full bg-blue-600 hover:bg-blue-700
67
+ text-white shadow-lg shadow-blue-600/30 flex items-center justify-center transition-colors
68
+ focus:outline-none focus:ring-4 focus:ring-blue-500/40"
69
+ >
70
+ <!-- Cincin sonar/ripple: 2 cincin memancar keluar (stagger) saat widget tertutup —
71
+ merah bila ada pesan belum dibaca (penarik perhatian), biru halus bila tidak.
72
+ pointer-events-none supaya tak ganggu klik; berhenti otomatis saat panel terbuka. -->
73
+ <template v-if="!isOpen">
74
+ <span
75
+ class="chat-ripple absolute inset-0 rounded-full border-2 pointer-events-none"
76
+ :class="unreadTotal > 0 ? 'border-red-500' : 'border-blue-400'"
77
+ aria-hidden="true"
78
+ />
79
+ <span
80
+ class="chat-ripple chat-ripple--delay absolute inset-0 rounded-full border-2 pointer-events-none"
81
+ :class="unreadTotal > 0 ? 'border-red-500' : 'border-blue-400'"
82
+ aria-hidden="true"
83
+ />
84
+ </template>
85
+
86
+ <X v-if="isOpen" class="w-6 h-6 relative z-10" />
87
+ <MessageSquareText v-else class="w-6 h-6 relative z-10" />
88
+ <!-- Badge unread total -->
89
+ <span
90
+ v-if="!isOpen && unreadTotal > 0"
91
+ class="absolute -top-0.5 -right-0.5 min-w-[20px] h-5 px-1 rounded-full bg-red-500 text-white
92
+ text-[10px] font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-800"
93
+ >{{ unreadTotal > 99 ? '99+' : unreadTotal }}</span>
94
+ </button>
95
+ </template>
96
+
97
+ <style scoped>
98
+ .chat-pop-enter-active,
99
+ .chat-pop-leave-active {
100
+ transition: opacity 0.2s ease, transform 0.2s ease;
101
+ transform-origin: bottom right;
102
+ }
103
+ .chat-pop-enter-from,
104
+ .chat-pop-leave-to {
105
+ opacity: 0;
106
+ transform: translateY(12px) scale(0.96);
107
+ }
108
+
109
+ /* Cincin sonar/ripple tombol chat — memancar keluar sambil memudar, berulang */
110
+ @keyframes chat-ripple {
111
+ 0% { transform: scale(1); opacity: 0.55; }
112
+ 100% { transform: scale(1.5); opacity: 0; }
113
+ }
114
+ .chat-ripple {
115
+ animation: chat-ripple 1.8s ease-out infinite;
116
+ transform-origin: center;
117
+ }
118
+ /* Cincin kedua di-delay setengah durasi → gelombang beruntun (2 cincin) */
119
+ .chat-ripple--delay {
120
+ animation-delay: 0.9s;
121
+ }
122
+ /* Hormati preferensi kurangi animasi (aksesibilitas) */
123
+ @media (prefers-reduced-motion: reduce) {
124
+ .chat-ripple { animation: none; opacity: 0; }
125
+ }
126
+ </style>
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ // Badge kanal percakapan — generik per `channel`. Semua kanal (webchat/WA/IG) masuk lewat pintu
3
+ // yang sama, jadi peta ini satu-satunya tempat yang menyebut kanal satu per satu.
4
+ // wa/ig/fb pakai glyph brand simple-icons via Iconify (pola TaskTypeIcon — @lucide/vue tak
5
+ // menyediakan ikon brand); warnanya IKUT warna teks chip supaya serasi light/dark.
6
+ import { computed } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+ import { Icon } from '@iconify/vue'
9
+ import { MessageSquare } from '@lucide/vue'
10
+ import whatsapp from '@iconify-icons/simple-icons/whatsapp'
11
+ import instagram from '@iconify-icons/simple-icons/instagram'
12
+ import facebook from '@iconify-icons/simple-icons/facebook'
13
+
14
+ const props = defineProps({
15
+ channel: { type: String, required: true },
16
+ // compact = ikon saja (baris daftar yang padat); default ikon + label
17
+ compact: { type: Boolean, default: false },
18
+ })
19
+
20
+ const { t } = useI18n()
21
+
22
+ const MAP = {
23
+ webchat: { lucide: MessageSquare, cls: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
24
+ wa: { brand: whatsapp, cls: 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
25
+ ig: { brand: instagram, cls: 'bg-fuchsia-50 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300' },
26
+ fb: { brand: facebook, cls: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300' },
27
+ }
28
+
29
+ const entry = computed(() => MAP[props.channel] ?? MAP.webchat)
30
+ // Kanal tak dikenal (kanal baru sebelum kamus menyusul) → tampilkan slug mentahnya apa adanya,
31
+ // jangan menyamarkannya sebagai webchat.
32
+ const label = computed(() =>
33
+ MAP[props.channel] ? t(`communications.channel.${props.channel}`) : props.channel
34
+ )
35
+ </script>
36
+
37
+ <template>
38
+ <span
39
+ class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium shrink-0"
40
+ :class="entry.cls"
41
+ :title="label"
42
+ >
43
+ <Icon v-if="entry.brand" :icon="entry.brand" class="w-3 h-3 shrink-0" />
44
+ <component v-else :is="entry.lucide" class="w-3 h-3 shrink-0" />
45
+ <span v-if="!compact">{{ label }}</span>
46
+ </span>
47
+ </template>