@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,439 @@
1
+ <script setup>
2
+ // Thread percakapan — SATU komponen bersama untuk layar Inbox DAN panel di drawer peluang
3
+ // (pola LineItemsEditor: aturan kontrak ditulis SEKALI di sini, jangan bikin thread kembar).
4
+ //
5
+ // Aturan kontrak yang dipegang komponen ini (tahap-7-communications-webchat.md):
6
+ // - `GET .../messages` paginated DESC → dirender DIBALIK (lama di atas, baru di bawah).
7
+ // - Buka thread = `POST .../read` (unread_count jadi 0) — emit('read') supaya daftar ikut turun.
8
+ // - Balas wa/ig bisa ditolak by-design; bercabang dari `data.reason`, BUKAN kalimat message:
9
+ // 422 outside_service_window → composer mati + keterangan (bukan error merah);
10
+ // 422 channel_not_connected → arahkan ke layar Koneksi Meta;
11
+ // 502 provider_send_failed → toast + teks TIDAK dibuang (user tinggal kirim ulang).
12
+ // - `window_expires_at` (wa/ig): countdown; webchat null = tanpa window, perilaku 7.3 utuh.
13
+ // - Centang delivered/read hanya utk pesan keluar (delivered_at/read_at, kini terisi utk wa).
14
+ // - Pesan `system` (close/reopen) dirender netral di tengah — pengunjung juga melihatnya.
15
+ // - Voice note WA (§3b): `type:"audio"` + `media_file_id` → player; transkrip STT ON-DEMAND
16
+ // (1 kredit AI/pesan) — tombol, tak pernah otomatis. Audio LAMA jatuh ke `type:"text"`
17
+ // body `[audio]` dan memang dirender sebagai teks biasa, bukan player rusak.
18
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
19
+ import { useI18n } from 'vue-i18n'
20
+ import { crmConversationsApi } from '@initsolusidigital/worksuite-core/api/endpoints/crmConversations.js'
21
+ import { crmMessagesApi } from '@initsolusidigital/worksuite-core/api/endpoints/crmMessages.js'
22
+ import { aiApi } from '@initsolusidigital/worksuite-core/api/endpoints/ai.js'
23
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
24
+ import { useToast } from '@initsolusidigital/worksuite-core/composables/useToast.js'
25
+ import { useModulePermission } from '@initsolusidigital/worksuite-core/composables/useModulePermission.js'
26
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
27
+ import AppAiBadge from '@initsolusidigital/worksuite-ui/AppAiBadge.vue'
28
+ import { Send, Check, CheckCheck, Clock, FileText, Loader2, Bot } from '@lucide/vue'
29
+
30
+ const props = defineProps({
31
+ companyId: { type: String, required: true },
32
+ conversation: { type: Object, required: true },
33
+ // Panel drawer memakai thread yang sama tapi lebih pendek — parent yang mengatur tinggi.
34
+ compact: { type: Boolean, default: false },
35
+ })
36
+ const emit = defineEmits(['read', 'sent', 'go-meta'])
37
+
38
+ const { t, locale } = useI18n()
39
+ const { pushToast } = useToast()
40
+ const { can } = useModulePermission()
41
+
42
+ const canUpdate = computed(() => can('communications', 'can_update'))
43
+
44
+ const messages = ref([]) // urut LAMA → BARU (hasil pembalikan halaman desc)
45
+ const loading = ref(false)
46
+ const loadError = ref(false)
47
+ const hasOlder = ref(false)
48
+ const oldestPage = ref(1)
49
+ const draft = ref('')
50
+ const sending = ref(false)
51
+ // Keterangan tolakan reply yang BERTAHAN di composer (bukan toast): 422 di luar jendela balas
52
+ // 24 jam, dan 422 kanal belum tersambung.
53
+ const replyNotice = ref(null) // 'outsideWindow' | 'notConnected' | null
54
+ const scroller = ref(null)
55
+
56
+ // ── Window 24 jam (wa/ig) ─────────────────────────────────────────────────────
57
+ // Dihitung ulang tiap 30 dtk; melewati batas = composer mati TANPA menunggu server menolak.
58
+ // `now` di-ref supaya computed ikut bergerak — tanpa ini countdown beku di render pertama.
59
+ const now = ref(Date.now())
60
+ let clockTimer = null
61
+ const windowExpiresAt = computed(() => {
62
+ const v = props.conversation?.window_expires_at
63
+ return v ? new Date(v).getTime() : null
64
+ })
65
+ const windowExpired = computed(() =>
66
+ windowExpiresAt.value !== null && windowExpiresAt.value <= now.value
67
+ )
68
+ const windowLeftText = computed(() => {
69
+ if (windowExpiresAt.value === null || windowExpired.value) return ''
70
+ const menit = Math.max(1, Math.floor((windowExpiresAt.value - now.value) / 60000))
71
+ const j = Math.floor(menit / 60), m = menit % 60
72
+ return j > 0 ? `${j} j ${m} m` : `${m} m`
73
+ })
74
+
75
+ const composerDisabled = computed(() =>
76
+ !canUpdate.value || windowExpired.value || replyNotice.value === 'outsideWindow'
77
+ )
78
+
79
+ // ── Muat pesan ────────────────────────────────────────────────────────────────
80
+ async function loadMessages({ silent = false } = {}) {
81
+ if (!props.conversation?.id) return
82
+ if (!silent) { loading.value = true; loadError.value = false }
83
+ try {
84
+ const { data } = await crmConversationsApi.messages(props.companyId, props.conversation.id, { page: 1 })
85
+ const pager = data.data
86
+ const rows = (pager?.data ?? pager ?? []).slice().reverse()
87
+ // Refresh senyap (polling/realtime) hanya MENAMBAH ekor — jangan menimpa halaman lama
88
+ // yang sudah dimuat lewat "Muat pesan sebelumnya".
89
+ if (silent && messages.value.length) {
90
+ const known = new Set(messages.value.map(m => m.id))
91
+ const fresh = rows.filter(m => !known.has(m.id))
92
+ if (fresh.length) {
93
+ messages.value.push(...fresh)
94
+ scrollToBottom()
95
+ // Pesan masuk baru me-reset window (tiap pesan masuk = +24 jam) — keterangan basi dicabut.
96
+ if (fresh.some(m => m.direction === 'in') && replyNotice.value === 'outsideWindow') replyNotice.value = null
97
+ }
98
+ return
99
+ }
100
+ messages.value = rows
101
+ hasOlder.value = !!pager?.next_page_url
102
+ oldestPage.value = 1
103
+ scrollToBottom()
104
+ } catch {
105
+ if (!silent) loadError.value = true
106
+ } finally {
107
+ if (!silent) loading.value = false
108
+ }
109
+ }
110
+
111
+ async function loadOlder() {
112
+ try {
113
+ const page = oldestPage.value + 1
114
+ const { data } = await crmConversationsApi.messages(props.companyId, props.conversation.id, { page })
115
+ const pager = data.data
116
+ const rows = (pager?.data ?? []).slice().reverse()
117
+ messages.value.unshift(...rows)
118
+ oldestPage.value = page
119
+ hasOlder.value = !!pager?.next_page_url
120
+ } catch {
121
+ pushToast({ title: t('communications.loadError'), variant: 'alert' })
122
+ }
123
+ }
124
+
125
+ function scrollToBottom() {
126
+ nextTick(() => {
127
+ if (scroller.value) scroller.value.scrollTop = scroller.value.scrollHeight
128
+ })
129
+ }
130
+
131
+ // ── Balas ─────────────────────────────────────────────────────────────────────
132
+ async function send() {
133
+ const body = draft.value.trim()
134
+ if (!body || sending.value || composerDisabled.value) return
135
+ sending.value = true
136
+ try {
137
+ const { data } = await crmConversationsApi.reply(props.companyId, props.conversation.id, { message: body })
138
+ const msg = data.data
139
+ if (msg?.id) messages.value.push(msg)
140
+ draft.value = ''
141
+ replyNotice.value = null
142
+ scrollToBottom()
143
+ emit('sent', msg)
144
+ } catch (err) {
145
+ const info = normalizeApiError(err)
146
+ if (info.reason === 'outside_service_window') {
147
+ replyNotice.value = 'outsideWindow' // keterangan bertahan, bukan error merah
148
+ } else if (info.reason === 'channel_not_connected') {
149
+ replyNotice.value = 'notConnected'
150
+ } else {
151
+ // 502 provider_send_failed & gangguan lain → toast (kanon), teks TIDAK dibuang
152
+ pushToast({ title: t('communications.sendError'), variant: 'alert' })
153
+ }
154
+ } finally {
155
+ sending.value = false
156
+ }
157
+ }
158
+
159
+ // ── Voice note: player + transkripsi on-demand (§3b) ──────────────────────────
160
+ // Add-on AI dibaca SEKALI per thread, bukan per bubble: satu percakapan bisa memuat puluhan
161
+ // voice note, dan menanyakan saldo tiap bubble berarti puluhan permintaan untuk satu jawaban
162
+ // yang sama. Dibaca lewat `Promise` yang di-cache supaya beberapa thread yang dibuka berurutan
163
+ // tak menembaknya berulang.
164
+ const addonAiAktif = ref(null) // null = belum diketahui → tombol ditahan, bukan disembunyikan
165
+ async function muatKeadaanAi() {
166
+ try {
167
+ const { data } = await aiApi.credits(props.companyId)
168
+ // `=== false` disengaja (pola AiWizard): field absen pada backend lama TIDAK boleh dibaca
169
+ // sebagai "tak berlangganan" — itu akan menyembunyikan tombol dari tenant yang berhak.
170
+ addonAiAktif.value = data?.data?.addon_active !== false
171
+ } catch {
172
+ // Gagal membaca saldo bukan alasan menyembunyikan fitur berbayar (gerbang entitlement di
173
+ // frontend selalu fail-open): gerbang sebenarnya tetap di backend, yang menjawab 403 kalau memang tak
174
+ // berhak — dan pesan itu jauh lebih jelas daripada tombol yang diam-diam hilang.
175
+ addonAiAktif.value = true
176
+ }
177
+ }
178
+
179
+ const transkripJalan = ref(new Set()) // id pesan yang sedang ditranskrip
180
+ const transkripGagal = ref(new Set()) // id pesan yang gagal 502 → tawarkan coba lagi
181
+
182
+ function bisaTranskrip(m) {
183
+ return m.type === 'audio' && !!m.media_file_id && !m.transcript && addonAiAktif.value === true
184
+ }
185
+
186
+ async function transkrip(m) {
187
+ if (transkripJalan.value.has(m.id)) return
188
+ transkripJalan.value = new Set(transkripJalan.value).add(m.id)
189
+ transkripGagal.value = new Set([...transkripGagal.value].filter(id => id !== m.id))
190
+ try {
191
+ const { data } = await crmMessagesApi.transcribe(props.companyId, m.id)
192
+ const hasil = data?.data?.transcript ?? ''
193
+ // Tulis ke objek pesan supaya render berikutnya (dan polling senyap) tak memanggil ulang —
194
+ // `cached:true` memang gratis, tapi tetap satu perjalanan bolak-balik yang tak perlu.
195
+ const target = messages.value.find(x => x.id === m.id)
196
+ if (target) target.transcript = hasil
197
+ } catch (err) {
198
+ const info = normalizeApiError(err)
199
+ if (info.reason === 'transcription_failed') {
200
+ // 502: provider gagal & kredit TIDAK terpotong — jadi "coba lagi" memang tindakan yang benar.
201
+ transkripGagal.value = new Set(transkripGagal.value).add(m.id)
202
+ pushToast({ title: t('communications.audio.failedRetry'), variant: 'alert' })
203
+ } else if (info.status === 402) {
204
+ pushToast({ title: t('communications.audio.noCredit'), variant: 'alert' })
205
+ } else if (info.status === 403) {
206
+ // Tombolnya memang sudah digerbang; kalau tetap sampai ke sini, keadaan add-on berubah
207
+ // di tengah sesi — sembunyikan tombolnya sekalian supaya tak ditekan berulang.
208
+ addonAiAktif.value = false
209
+ pushToast({ title: t('communications.audio.noAddon'), variant: 'alert' })
210
+ } else if (info.reason === 'not_transcribable') {
211
+ pushToast({ title: t('communications.audio.notTranscribable'), variant: 'alert' })
212
+ } else {
213
+ pushToast({ title: t('communications.audio.failed'), variant: 'alert' })
214
+ }
215
+ } finally {
216
+ transkripJalan.value = new Set([...transkripJalan.value].filter(id => id !== m.id))
217
+ }
218
+ }
219
+
220
+ const mediaUrl = (m) => crmMessagesApi.mediaUrl(props.companyId, m.id)
221
+
222
+ // ── Render helper ─────────────────────────────────────────────────────────────
223
+ function timeOf(m) {
224
+ const d = new Date(m.created_at)
225
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(locale.value === 'id' ? 'id-ID' : 'en-US', { hour: '2-digit', minute: '2-digit' })
226
+ }
227
+ function dayOf(m) {
228
+ const d = new Date(m.created_at)
229
+ return Number.isNaN(d.getTime()) ? '' : d.toDateString()
230
+ }
231
+ function dayLabel(m) {
232
+ const d = new Date(m.created_at)
233
+ return d.toLocaleDateString(locale.value === 'id' ? 'id-ID' : 'en-US', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
234
+ }
235
+ function showDayDivider(idx) {
236
+ if (idx === 0) return true
237
+ return dayOf(messages.value[idx]) !== dayOf(messages.value[idx - 1])
238
+ }
239
+ // Centang status pesan keluar: read ✓✓ biru · delivered ✓✓ abu · selain itu ✓ (tersimpan).
240
+ function tickOf(m) {
241
+ if (m.read_at) return { icon: CheckCheck, cls: 'text-blue-500 dark:text-blue-400' }
242
+ if (m.delivered_at) return { icon: CheckCheck, cls: 'text-gray-400' }
243
+ return { icon: Check, cls: 'text-gray-400' }
244
+ }
245
+
246
+ // ── Lifecycle: markRead saat dibuka, polling senyap sebagai fallback realtime ──
247
+ let pollTimer = null
248
+ async function openConversation() {
249
+ replyNotice.value = null
250
+ draft.value = ''
251
+ await loadMessages()
252
+ if ((props.conversation?.unread_count ?? 0) > 0) {
253
+ try {
254
+ await crmConversationsApi.markRead(props.companyId, props.conversation.id)
255
+ emit('read', props.conversation.id)
256
+ } catch { /* badge menyusul lewat polling daftar — bukan alasan menahan thread */ }
257
+ }
258
+ }
259
+
260
+ defineExpose({ refresh: () => loadMessages({ silent: true }) })
261
+
262
+ watch(() => props.conversation?.id, () => { openConversation() })
263
+
264
+ onMounted(() => {
265
+ openConversation()
266
+ muatKeadaanAi()
267
+ // Polling = fallback SETARA per kontrak §4 — tetap jalan walau Reverb hidup (idempoten,
268
+ // refresh senyap hanya menambah pesan baru). 12 dtk: cukup responsif tanpa membebani throttle.
269
+ pollTimer = setInterval(() => {
270
+ if (!document.hidden) loadMessages({ silent: true })
271
+ }, 12000)
272
+ clockTimer = setInterval(() => { now.value = Date.now() }, 30000)
273
+ })
274
+ onBeforeUnmount(() => {
275
+ clearInterval(pollTimer)
276
+ clearInterval(clockTimer)
277
+ })
278
+ </script>
279
+
280
+ <template>
281
+ <div class="flex flex-col h-full min-h-0">
282
+ <!-- Chip window 24 jam (wa/ig saja) — satu-satunya elemen "hidup" di header thread -->
283
+ <div
284
+ v-if="windowExpiresAt !== null"
285
+ class="shrink-0 px-4 py-1.5 text-xs flex items-center gap-1.5 border-b border-gray-100 dark:border-gray-700"
286
+ :class="windowExpired ? 'text-amber-700 bg-amber-50 dark:text-amber-300 dark:bg-amber-900/20' : 'text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/40'"
287
+ >
288
+ <Clock class="w-3.5 h-3.5 shrink-0" />
289
+ {{ windowExpired ? t('communications.windowExpired') : t('communications.windowLeft', { time: windowLeftText }) }}
290
+ </div>
291
+
292
+ <!-- Area pesan -->
293
+ <div ref="scroller" class="flex-1 min-h-0 overflow-y-auto scrollbar-rounded px-4 py-3 space-y-1 bg-gray-50 dark:bg-gray-900/40">
294
+ <div v-if="loading" class="text-center text-sm text-gray-400 py-8">{{ t('common.loading') }}</div>
295
+ <div v-else-if="loadError" class="text-center text-sm text-gray-500 py-8">
296
+ {{ t('communications.loadError') }}
297
+ <AppButton variant="secondary" size="sm" class="mt-2" @click="loadMessages()">{{ t('common.retry') }}</AppButton>
298
+ </div>
299
+ <template v-else>
300
+ <div v-if="hasOlder" class="text-center pb-2">
301
+ <button class="text-xs text-blue-600 dark:text-blue-400 hover:underline" @click="loadOlder">
302
+ {{ t('communications.loadOlder') }}
303
+ </button>
304
+ </div>
305
+
306
+ <template v-for="(m, idx) in messages" :key="m.id">
307
+ <div v-if="showDayDivider(idx)" class="text-center py-2">
308
+ <span class="text-[11px] text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-full px-2.5 py-0.5">{{ dayLabel(m) }}</span>
309
+ </div>
310
+
311
+ <!-- Pesan system (tutup/buka) — netral di tengah, ikut terlihat pengunjung -->
312
+ <div v-if="m.type === 'system'" class="text-center py-1">
313
+ <span class="text-[11px] italic text-gray-400 dark:text-gray-500">{{ m.body }}</span>
314
+ </div>
315
+
316
+ <!-- Voice note WA (§3b) — bubble ber-player. Sengaja BUKAN cabang di dalam bubble teks:
317
+ isinya kontrol, bukan kalimat, jadi `whitespace-pre-wrap`/`break-words` di sana tak
318
+ berlaku dan lebarnya diatur sendiri agar player tak menyusut jadi tak bisa dipakai. -->
319
+ <div v-else-if="m.type === 'audio'" class="flex" :class="m.direction === 'out' ? 'justify-end' : 'justify-start'">
320
+ <div
321
+ class="max-w-[85%] min-w-0 rounded-2xl px-3 py-2"
322
+ :class="m.direction === 'out'
323
+ ? 'bg-blue-600 text-white rounded-br-sm'
324
+ : 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-100 dark:border-gray-700 rounded-bl-sm'"
325
+ >
326
+ <!-- ⚠️ TANPA atribut `crossorigin` — lihat catatan di `crmMessagesApi.mediaUrl`. -->
327
+ <audio
328
+ controls
329
+ preload="none"
330
+ :src="mediaUrl(m)"
331
+ class="w-56 sm:w-64 max-w-full"
332
+ :aria-label="t('communications.audio.playerLabel')"
333
+ ></audio>
334
+
335
+ <!-- Transkrip: hasil AI, jadi WAJIB berpenanda (kanon penanda AI = layar staf saja) -->
336
+ <div v-if="m.transcript" class="mt-1.5 pt-1.5 border-t text-sm"
337
+ :class="m.direction === 'out' ? 'border-blue-500/40' : 'border-gray-100 dark:border-gray-700'">
338
+ <AppAiBadge :title="t('communications.audio.transcriptTitle')" />
339
+ <p class="mt-1 whitespace-pre-wrap break-words">{{ m.transcript }}</p>
340
+ </div>
341
+
342
+ <!-- Tombol transkrip: aksi BERBAYAR (1 kredit) → selalu dipicu manusia, tak pernah
343
+ otomatis saat thread dibuka. Disembunyikan (bukan di-disable) saat add-on mati. -->
344
+ <button
345
+ v-else-if="bisaTranskrip(m)"
346
+ type="button"
347
+ class="mt-1.5 inline-flex items-center gap-1 text-xs font-medium rounded px-1.5 py-0.5 transition-colors disabled:opacity-60"
348
+ :class="m.direction === 'out'
349
+ ? 'text-blue-100 hover:bg-blue-500/40'
350
+ : 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'"
351
+ :disabled="transkripJalan.has(m.id)"
352
+ @click="transkrip(m)"
353
+ >
354
+ <Loader2 v-if="transkripJalan.has(m.id)" class="w-3.5 h-3.5 shrink-0 animate-spin" aria-hidden="true" />
355
+ <FileText v-else class="w-3.5 h-3.5 shrink-0" aria-hidden="true" />
356
+ {{ transkripJalan.has(m.id)
357
+ ? t('communications.audio.transcribing')
358
+ : (transkripGagal.has(m.id) ? t('common.retry') : t('communications.audio.transcribe')) }}
359
+ </button>
360
+
361
+ <div class="flex items-center justify-end gap-0.5 text-[10px] mt-0.5"
362
+ :class="m.direction === 'out' ? 'text-blue-100' : 'text-gray-400'">
363
+ {{ timeOf(m) }}
364
+ <component
365
+ v-if="m.direction === 'out'"
366
+ :is="tickOf(m).icon"
367
+ class="w-3.5 h-3.5 shrink-0"
368
+ :class="m.read_at ? 'text-sky-300' : 'text-blue-200'"
369
+ />
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <div v-else class="flex" :class="m.direction === 'out' ? 'justify-end' : 'justify-start'" :data-bot="m.is_bot || undefined">
375
+ <div
376
+ class="max-w-[78%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words"
377
+ :class="m.is_bot
378
+ ? 'bg-violet-600 text-white rounded-br-sm'
379
+ : m.direction === 'out'
380
+ ? 'bg-blue-600 text-white rounded-br-sm'
381
+ : 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-100 dark:border-gray-700 rounded-bl-sm'"
382
+ ><!-- Balasan bot dibedakan warnanya & berlabel: pelanggan melihatnya sebagai jawaban
383
+ otomatis, dan agen perlu tahu kalimat mana yang BUKAN tulisan timnya. Sumbernya
384
+ (`bot_citations`, tak pernah dikirim ke pelanggan) bisa dibuka di sini. --><span
385
+ v-if="m.is_bot"
386
+ class="flex items-center gap-1 mb-1 text-[10px] font-medium uppercase tracking-wide text-violet-100 whitespace-normal"
387
+ ><Bot class="w-3 h-3 shrink-0" />{{ t('communications.bot.label') }}</span>{{ m.body }}<details
388
+ v-if="m.is_bot && m.bot_citations?.length"
389
+ class="mt-1.5 text-[11px] text-violet-100 whitespace-normal"
390
+ ><summary class="cursor-pointer select-none">{{ t('communications.bot.sources', { n: m.bot_citations.length }) }}</summary><ul class="mt-1 space-y-0.5 list-none"><li
391
+ v-for="c in m.bot_citations"
392
+ :key="c.n"
393
+ >[{{ c.n }}] {{ c.title }}<template v-if="c.heading"> · {{ c.heading }}</template><template v-if="c.page"> · {{ t('communications.bot.page', { page: c.page }) }}</template></li></ul></details><span
394
+ class="inline-flex items-center gap-0.5 text-[10px] ml-2 align-bottom"
395
+ :class="m.direction === 'out' ? 'text-blue-100' : 'text-gray-400'"
396
+ >{{ timeOf(m) }}<component
397
+ v-if="m.direction === 'out'"
398
+ :is="tickOf(m).icon"
399
+ class="w-3.5 h-3.5 shrink-0"
400
+ :class="m.direction === 'out' ? (m.read_at ? 'text-sky-300' : 'text-blue-200') : ''"
401
+ /></span></div>
402
+ </div>
403
+ </template>
404
+
405
+ <div v-if="!messages.length" class="text-center text-sm text-gray-400 py-8">
406
+ {{ t('communications.emptyThread') }}
407
+ </div>
408
+ </template>
409
+ </div>
410
+
411
+ <!-- Keterangan tolakan reply (bertahan sampai keadaannya berubah) -->
412
+ <div v-if="replyNotice" class="shrink-0 px-4 py-2 text-xs border-t border-gray-100 dark:border-gray-700"
413
+ :class="replyNotice === 'outsideWindow' ? 'text-amber-700 bg-amber-50 dark:text-amber-300 dark:bg-amber-900/20' : 'text-red-700 bg-red-50 dark:text-red-300 dark:bg-red-900/20'"
414
+ >
415
+ {{ t(replyNotice === 'outsideWindow' ? 'communications.reason.outsideWindow' : 'communications.reason.notConnected') }}
416
+ <button
417
+ v-if="replyNotice === 'notConnected' && can('communications', 'can_configure')"
418
+ class="ml-1 underline font-medium"
419
+ @click="emit('go-meta')"
420
+ >{{ t('communications.goToMeta') }}</button>
421
+ </div>
422
+
423
+ <!-- Composer -->
424
+ <div class="shrink-0 border-t border-gray-100 dark:border-gray-700 p-3 flex items-end gap-2 bg-white dark:bg-gray-800">
425
+ <textarea
426
+ v-model="draft"
427
+ :placeholder="composerDisabled ? t('communications.windowExpired') : t('communications.replyPlaceholder')"
428
+ :aria-label="t('communications.replyPlaceholder')"
429
+ :disabled="composerDisabled"
430
+ rows="1"
431
+ class="flex-1 resize-none rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-900 dark:text-white text-sm focus:border-blue-500 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-400 dark:disabled:bg-gray-800 max-h-32 overflow-y-auto no-scrollbar"
432
+ @keydown.enter.exact.prevent="send"
433
+ ></textarea>
434
+ <AppButton variant="primary" :disabled="composerDisabled || !draft.trim() || sending" :aria-label="t('communications.send')" @click="send">
435
+ <Send class="w-4 h-4" />
436
+ </AppButton>
437
+ </div>
438
+ </div>
439
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup>
2
+ // Panel filter custom fields — collapsible di bawah toolbar tabel (slot #subtoolbar
3
+ // AppDataTable). Input dirender CustomFieldInput mode 'filter' (kontrak backend:
4
+ // exact/contains, SATU nilai per field, belum ada range). Nilai '' = tidak difilter.
5
+ // Parent yang memetakan object nilai ini ke param `custom_field[code]=`.
6
+ import { useI18n } from 'vue-i18n'
7
+ import { RotateCcw } from '@lucide/vue'
8
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
9
+ import CustomFieldInput from './CustomFieldInput.vue'
10
+
11
+ const props = defineProps({
12
+ // Definisi ber-settings.is_filterable=true saja (tipe file dikecualikan parent)
13
+ definitions: { type: Array, default: () => [] },
14
+ // Object nilai filter { code: value } — v-model
15
+ modelValue: { type: Object, default: () => ({}) },
16
+ })
17
+
18
+ const emit = defineEmits(['update:modelValue'])
19
+
20
+ const { t } = useI18n()
21
+
22
+ function setField(code, val) {
23
+ emit('update:modelValue', { ...props.modelValue, [code]: val })
24
+ }
25
+
26
+ function reset() {
27
+ emit('update:modelValue', {})
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-4">
33
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
34
+ <CustomFieldInput
35
+ v-for="field in definitions"
36
+ :key="field.id"
37
+ :field="field"
38
+ mode="filter"
39
+ :model-value="modelValue[field.code] ?? ''"
40
+ @update:model-value="setField(field.code, $event)"
41
+ />
42
+ </div>
43
+ <div class="flex justify-end mt-4">
44
+ <AppButton variant="secondary" size="sm" @click="reset">
45
+ <RotateCcw class="w-3.5 h-3.5" />
46
+ {{ t('common.customFields.filterReset') }}
47
+ </AppButton>
48
+ </div>
49
+ </div>
50
+ </template>