@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.
- package/LICENSE +16 -0
- package/README.md +122 -2
- package/package.json +44 -4
- package/src/components/ai/AppAssistantDraft.vue +120 -0
- package/src/components/ai/AppAssistantWidget.vue +245 -0
- package/src/components/ai/AppCitedAnswer.vue +86 -0
- package/src/components/approvals/AppApprovalInbox.vue +265 -0
- package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
- package/src/components/chat/AppChatBubble.vue +324 -0
- package/src/components/chat/AppChatList.vue +249 -0
- package/src/components/chat/AppChatPanel.vue +315 -0
- package/src/components/chat/AppChatWidget.vue +126 -0
- package/src/components/communications/AppChannelBadge.vue +47 -0
- package/src/components/communications/AppConversationThread.vue +439 -0
- package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
- package/src/components/customFields/CustomFieldInput.vue +422 -0
- package/src/components/customFields/CustomFieldValue.vue +129 -0
- package/src/components/customFields/CustomFieldsSection.vue +65 -0
- package/src/components/customFields/fieldTypes.js +23 -0
- package/src/components/dashboard/AppCustomWidget.vue +138 -0
- package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
- package/src/components/dashboard/AppDashboardGrid.vue +200 -0
- package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
- package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
- package/src/components/dataTools/AppImportExport.vue +724 -0
- package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
- package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
- package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
- package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
- package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
- package/src/components/kpi/AppKpiAppraisals.vue +733 -0
- package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
- package/src/components/kpi/AppKpiEntryModal.vue +122 -0
- package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
- package/src/components/kpi/AppKpiMyCard.vue +134 -0
- package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
- package/src/components/kpi/AppKpiReportTable.vue +214 -0
- package/src/components/kpi/AppKpiScorecard.vue +242 -0
- package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
- package/src/components/kpi/AppKpiTeam.vue +221 -0
- package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
- package/src/components/kpi/AppKpiWorkspace.vue +479 -0
- package/src/components/layout/AppGlobalSearch.vue +149 -0
- package/src/components/layout/AppLanguagePopup.vue +94 -0
- package/src/components/layout/AppLauncherPopup.vue +147 -0
- package/src/components/layout/AppLegalConsentModal.vue +484 -0
- package/src/components/layout/AppNavbar.vue +402 -0
- package/src/components/layout/AppNotificationContent.vue +173 -0
- package/src/components/layout/AppNotificationDrawer.vue +98 -0
- package/src/components/layout/AppNotificationPopup.vue +67 -0
- package/src/components/layout/AppPlatformBanners.vue +192 -0
- package/src/components/layout/AppSidebar.vue +226 -0
- package/src/components/layout/AppSidebarNav.vue +119 -0
- package/src/components/layout/AppUserMenu.vue +89 -0
- package/src/components/layout/AppUserMenuContent.vue +59 -0
- package/src/composables/useKpi.js +232 -0
- package/src/composables/useLgViewport.js +25 -0
- package/src/composables/useNavigation.js +177 -0
- package/src/layouts/AppLayout.vue +207 -0
- package/src/locales/en.json +1362 -0
- package/src/locales/id.json +1396 -0
- package/src/pages/auth/AccountBlocked.vue +55 -0
- package/src/pages/auth/IdentityRedirect.vue +37 -0
- package/src/pages/profile/Layout.vue +210 -0
- package/src/pages/profile/partials/AccountSection.vue +139 -0
- package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
- package/src/pages/profile/partials/DevicesCard.vue +222 -0
- package/src/pages/profile/partials/PreferencesSection.vue +87 -0
- package/src/pages/profile/partials/SecuritySection.vue +93 -0
- package/src/pages/profile/partials/SessionsCard.vue +418 -0
- package/src/pages/profile/partials/SessionsSection.vue +58 -0
- package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
- package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
- package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
- 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>
|