@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,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: '© 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>
|