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