@finema/finework-layer 2.0.61 → 2.0.62
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/CHANGELOG.md +6 -0
- package/app/app.vue +1 -1
- package/app/assets/css/main.css +5 -0
- package/app/components/HelpHub/Action.vue +28 -0
- package/app/components/HelpHub/ManualForm.vue +224 -0
- package/app/components/HelpHub/ManualViewer.vue +464 -0
- package/app/components/HelpHub/index.vue +116 -0
- package/app/composables/useCurrentModule.ts +9 -0
- package/app/constants/module-route.ts +48 -0
- package/app/loaders/manual/manual.ts +60 -0
- package/docs/help-hub.md +100 -0
- package/package.json +1 -1
- package/public/admin/asset.svg +15 -0
- package/public/admin/clock-in-admin-logo.svg +28 -0
- package/public/admin/clock-in-logo.svg +22 -0
- package/public/admin/contract.svg +36 -0
- package/public/admin/cost-sheet.svg +14 -0
- package/public/admin/document.svg +24 -0
- package/public/admin/efactoring.svg +18 -0
- package/public/admin/employee.svg +9 -0
- package/public/admin/evaluation.svg +24 -0
- package/public/admin/finesign.png +0 -0
- package/public/admin/guarantee.svg +20 -0
- package/public/admin/loan.svg +30 -0
- package/public/admin/newsletter.svg +18 -0
- package/public/admin/pmo-logo.svg +23 -0
- package/public/admin/pmo_delivery.svg +16 -0
- package/public/admin/pmo_presale_bidding.svg +25 -0
- package/public/admin/pmo_setup.svg +13 -0
- package/public/admin/pmo_warranty.svg +21 -0
- package/public/admin/presale_tool.svg +38 -0
- package/public/admin/purchase.svg +17 -0
- package/public/admin/quotation.svg +16 -0
- package/public/admin/recruit.svg +16 -0
- package/public/admin/report.svg +36 -0
- package/public/admin/request-admin.svg +39 -0
- package/public/admin/request.svg +37 -0
- package/public/admin/resource-allocation.svg +21 -0
- package/public/admin/resource.svg +15 -0
- package/public/admin/spider-web.png +0 -0
- package/public/admin/super-admin-logo.svg +17 -0
- package/public/admin/support.svg +14 -0
- package/public/admin/timesheet-admin-logo.svg +82 -0
- package/public/admin/timesheet-logo.svg +80 -0
- package/public/admin/todo.svg +16 -0
- package/public/admin/tor.svg +38 -0
- package/app/components/FeedbackTicket/index.vue +0 -27
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Slideover
|
|
3
|
+
:open="isOpen"
|
|
4
|
+
:portal="true"
|
|
5
|
+
:modal="false"
|
|
6
|
+
:dismissible="false"
|
|
7
|
+
:ui="{ content: 'w-full max-w-[560px]' }"
|
|
8
|
+
side="right"
|
|
9
|
+
@update:open="(val) => emit('update', val)"
|
|
10
|
+
>
|
|
11
|
+
<template #content>
|
|
12
|
+
<div class="flex h-full flex-col">
|
|
13
|
+
<!-- Header -->
|
|
14
|
+
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
|
15
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
16
|
+
<button
|
|
17
|
+
v-if="mode !== 'list'"
|
|
18
|
+
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
19
|
+
type="button"
|
|
20
|
+
title="กลับไปหน้ารายการ"
|
|
21
|
+
@click="backToList"
|
|
22
|
+
>
|
|
23
|
+
<Icon
|
|
24
|
+
name="ph:arrow-left"
|
|
25
|
+
class="size-5"
|
|
26
|
+
/>
|
|
27
|
+
</button>
|
|
28
|
+
<div
|
|
29
|
+
v-else
|
|
30
|
+
class="bg-primary-100 flex size-9 shrink-0 items-center justify-center rounded-lg"
|
|
31
|
+
>
|
|
32
|
+
<Icon
|
|
33
|
+
name="ph:book-open"
|
|
34
|
+
class="text-primary-600 size-5"
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="min-w-0">
|
|
38
|
+
<h2 class="truncate text-base font-semibold text-gray-900">
|
|
39
|
+
{{ headerTitle }}
|
|
40
|
+
</h2>
|
|
41
|
+
<p class="truncate text-xs text-gray-500">
|
|
42
|
+
{{ headerSubtitle }}
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
47
|
+
<a
|
|
48
|
+
v-if="mode === 'view' && current?.file"
|
|
49
|
+
:href="current.file.url"
|
|
50
|
+
target="_blank"
|
|
51
|
+
rel="noopener"
|
|
52
|
+
class="flex size-8 cursor-pointer items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
53
|
+
title="เปิดแบบเต็มจอ"
|
|
54
|
+
>
|
|
55
|
+
<Icon
|
|
56
|
+
name="ph:arrow-square-out"
|
|
57
|
+
class="size-5"
|
|
58
|
+
/>
|
|
59
|
+
</a>
|
|
60
|
+
<a
|
|
61
|
+
v-if="mode === 'view' && current?.file"
|
|
62
|
+
:href="current.file.url"
|
|
63
|
+
:download="`${current.name}.pdf`"
|
|
64
|
+
class="flex size-8 cursor-pointer items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
65
|
+
title="ดาวน์โหลด PDF"
|
|
66
|
+
>
|
|
67
|
+
<Icon
|
|
68
|
+
name="ph:download-simple"
|
|
69
|
+
class="size-5"
|
|
70
|
+
/>
|
|
71
|
+
</a>
|
|
72
|
+
<button
|
|
73
|
+
v-if="mode === 'view' && current && canManage(current.app)"
|
|
74
|
+
class="flex size-8 cursor-pointer items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
75
|
+
type="button"
|
|
76
|
+
title="แก้ไข"
|
|
77
|
+
@click="startEdit(current)"
|
|
78
|
+
>
|
|
79
|
+
<Icon
|
|
80
|
+
name="ph:pencil-simple"
|
|
81
|
+
class="size-5"
|
|
82
|
+
/>
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
class="flex size-8 cursor-pointer items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
|
86
|
+
type="button"
|
|
87
|
+
@click="emit('update', false)"
|
|
88
|
+
>
|
|
89
|
+
<Icon
|
|
90
|
+
name="ph:x"
|
|
91
|
+
class="size-5"
|
|
92
|
+
/>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- List -->
|
|
98
|
+
<template v-if="mode === 'list'">
|
|
99
|
+
<div class="flex-1 overflow-y-auto p-3">
|
|
100
|
+
<Loader :loading="loading && !manuals.length">
|
|
101
|
+
<div
|
|
102
|
+
v-if="!groups.length"
|
|
103
|
+
class="flex flex-col items-center justify-center gap-2 px-8 py-16 text-center"
|
|
104
|
+
>
|
|
105
|
+
<div class="mb-2 flex size-16 items-center justify-center rounded-full bg-gray-100 text-gray-400">
|
|
106
|
+
<Icon
|
|
107
|
+
name="ph:book-open"
|
|
108
|
+
class="size-8"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
<h3 class="text-base font-bold text-gray-900">
|
|
112
|
+
ยังไม่มีคู่มือ
|
|
113
|
+
</h3>
|
|
114
|
+
<p class="max-w-xs text-sm text-gray-500">
|
|
115
|
+
{{ canManageAny
|
|
116
|
+
? 'เริ่มต้นด้วยการเพิ่มคู่มือของแอพที่คุณดูแล'
|
|
117
|
+
: 'ทีมงานกำลังจัดทำคู่มืออยู่ หากติดปัญหาแจ้งทีมได้ผ่าน Feedback' }}
|
|
118
|
+
</p>
|
|
119
|
+
<Button
|
|
120
|
+
v-if="!canManageAny"
|
|
121
|
+
class="mt-3"
|
|
122
|
+
icon="ph:chat-circle-text"
|
|
123
|
+
@click="emit('feedback')"
|
|
124
|
+
>
|
|
125
|
+
ส่ง Feedback
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div
|
|
130
|
+
v-for="group in groups"
|
|
131
|
+
:key="group.app"
|
|
132
|
+
class="mb-3 last:mb-0"
|
|
133
|
+
>
|
|
134
|
+
<div class="flex items-center gap-2 px-2 pb-1">
|
|
135
|
+
<img
|
|
136
|
+
:src="logoOf(group.app)"
|
|
137
|
+
:alt="group.label"
|
|
138
|
+
class="size-4 rounded"
|
|
139
|
+
/>
|
|
140
|
+
<span class="text-xs font-semibold tracking-wide text-gray-500 uppercase">
|
|
141
|
+
{{ group.label }}
|
|
142
|
+
</span>
|
|
143
|
+
<span
|
|
144
|
+
v-if="group.app === currentModule"
|
|
145
|
+
class="bg-primary-50 text-primary-600 rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
|
146
|
+
>
|
|
147
|
+
หน้านี้
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<button
|
|
151
|
+
v-for="manual in group.items"
|
|
152
|
+
:key="manual.id"
|
|
153
|
+
class="group flex w-full cursor-pointer items-center gap-3 rounded-xl p-3 text-left transition hover:bg-gray-50"
|
|
154
|
+
type="button"
|
|
155
|
+
@click="openManual(manual)"
|
|
156
|
+
>
|
|
157
|
+
<div class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500">
|
|
158
|
+
<Icon
|
|
159
|
+
name="ph:file-pdf"
|
|
160
|
+
class="size-5"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="min-w-0 flex-1">
|
|
164
|
+
<div class="truncate font-semibold text-gray-900">
|
|
165
|
+
{{ manual.name }}
|
|
166
|
+
</div>
|
|
167
|
+
<div class="truncate text-xs text-gray-500">
|
|
168
|
+
{{ manual.description || `ปรับปรุง ${formatDate(manual.updated_at)}` }}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
<span
|
|
172
|
+
v-if="canManage(manual.app)"
|
|
173
|
+
class="flex items-center gap-1 opacity-0 transition group-hover:opacity-100"
|
|
174
|
+
>
|
|
175
|
+
<span
|
|
176
|
+
class="flex size-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
|
177
|
+
title="แก้ไข"
|
|
178
|
+
@click.stop="startEdit(manual)"
|
|
179
|
+
>
|
|
180
|
+
<Icon
|
|
181
|
+
name="ph:pencil-simple"
|
|
182
|
+
class="size-4"
|
|
183
|
+
/>
|
|
184
|
+
</span>
|
|
185
|
+
<span
|
|
186
|
+
class="flex size-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-100 hover:text-red-600"
|
|
187
|
+
title="ลบ"
|
|
188
|
+
@click.stop="confirmDelete = manual"
|
|
189
|
+
>
|
|
190
|
+
<Icon
|
|
191
|
+
name="ph:trash"
|
|
192
|
+
class="size-4"
|
|
193
|
+
/>
|
|
194
|
+
</span>
|
|
195
|
+
</span>
|
|
196
|
+
<Icon
|
|
197
|
+
v-else
|
|
198
|
+
name="ph:caret-right"
|
|
199
|
+
class="size-4 shrink-0 text-gray-300"
|
|
200
|
+
/>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</Loader>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="flex items-center justify-between gap-3 border-t border-gray-100 px-5 py-3">
|
|
207
|
+
<span class="text-xs text-gray-500">
|
|
208
|
+
ไม่เจอคู่มือที่ต้องการ?
|
|
209
|
+
<button
|
|
210
|
+
class="text-primary-600 cursor-pointer font-semibold hover:underline"
|
|
211
|
+
type="button"
|
|
212
|
+
@click="emit('feedback')"
|
|
213
|
+
>
|
|
214
|
+
ส่ง Feedback
|
|
215
|
+
</button>
|
|
216
|
+
</span>
|
|
217
|
+
<Button
|
|
218
|
+
v-if="canManageAny"
|
|
219
|
+
size="sm"
|
|
220
|
+
icon="ph:plus"
|
|
221
|
+
variant="outline"
|
|
222
|
+
color="neutral"
|
|
223
|
+
@click="startCreate"
|
|
224
|
+
>
|
|
225
|
+
เพิ่มคู่มือ
|
|
226
|
+
</Button>
|
|
227
|
+
</div>
|
|
228
|
+
</template>
|
|
229
|
+
|
|
230
|
+
<!-- Form (create / edit) -->
|
|
231
|
+
<div
|
|
232
|
+
v-else-if="mode === 'form'"
|
|
233
|
+
class="flex flex-1 flex-col overflow-hidden"
|
|
234
|
+
>
|
|
235
|
+
<HelpHubManualForm
|
|
236
|
+
:manual="editing"
|
|
237
|
+
:default-app="editing ? editing.app : (currentModule || undefined)"
|
|
238
|
+
@saved="onSaved"
|
|
239
|
+
@cancel="backToList"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<!-- View -->
|
|
244
|
+
<div
|
|
245
|
+
v-else
|
|
246
|
+
class="relative flex-1 overflow-hidden bg-gray-600"
|
|
247
|
+
>
|
|
248
|
+
<iframe
|
|
249
|
+
v-if="current?.file"
|
|
250
|
+
:key="current.file.url"
|
|
251
|
+
:src="`${current.file.url}#view=FitH`"
|
|
252
|
+
title="User manual"
|
|
253
|
+
class="size-full border-0"
|
|
254
|
+
/>
|
|
255
|
+
<div
|
|
256
|
+
v-else
|
|
257
|
+
class="flex h-full items-center justify-center bg-white text-sm text-gray-500"
|
|
258
|
+
>
|
|
259
|
+
ไม่พบไฟล์คู่มือ
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</template>
|
|
264
|
+
</Slideover>
|
|
265
|
+
|
|
266
|
+
<Modal
|
|
267
|
+
:open="!!confirmDelete"
|
|
268
|
+
:ui="{ content: 'max-w-sm' }"
|
|
269
|
+
@update:open="(v) => { if (!v) confirmDelete = null }"
|
|
270
|
+
>
|
|
271
|
+
<template #content>
|
|
272
|
+
<div class="flex flex-col gap-4 p-6">
|
|
273
|
+
<div>
|
|
274
|
+
<h3 class="text-base font-semibold text-gray-900">
|
|
275
|
+
ลบคู่มือ
|
|
276
|
+
</h3>
|
|
277
|
+
<p class="mt-1 text-sm text-gray-500">
|
|
278
|
+
ลบ “{{ confirmDelete?.name }}” ออกจากระบบ? ไฟล์ PDF จะยังอยู่ในคลังไฟล์
|
|
279
|
+
</p>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="flex justify-end gap-2">
|
|
282
|
+
<Button
|
|
283
|
+
variant="outline"
|
|
284
|
+
color="neutral"
|
|
285
|
+
@click="confirmDelete = null"
|
|
286
|
+
>
|
|
287
|
+
ยกเลิก
|
|
288
|
+
</Button>
|
|
289
|
+
<Button
|
|
290
|
+
color="error"
|
|
291
|
+
:loading="deleteLoader.status.value.isLoading"
|
|
292
|
+
@click="runDelete"
|
|
293
|
+
>
|
|
294
|
+
ลบ
|
|
295
|
+
</Button>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</template>
|
|
299
|
+
</Modal>
|
|
300
|
+
</template>
|
|
301
|
+
|
|
302
|
+
<script lang="ts" setup>
|
|
303
|
+
import type { IManual } from '#layer/app/loaders/manual/manual'
|
|
304
|
+
import { useManualDeleteLoader } from '#layer/app/loaders/manual/manual'
|
|
305
|
+
import { Permission, UserModuleLabel, UserModuleLogo } from '#layer/app/composables/useAuth'
|
|
306
|
+
import type { UserModule } from '#layer/app/composables/useAuth'
|
|
307
|
+
|
|
308
|
+
const emit = defineEmits<{
|
|
309
|
+
(e: 'update', value: boolean): void
|
|
310
|
+
(e: 'refetch'): void
|
|
311
|
+
(e: 'feedback'): void
|
|
312
|
+
}>()
|
|
313
|
+
|
|
314
|
+
const props = defineProps<{
|
|
315
|
+
isOpen: boolean
|
|
316
|
+
manuals: IManual[]
|
|
317
|
+
currentModule: UserModule | null
|
|
318
|
+
loading: boolean
|
|
319
|
+
}>()
|
|
320
|
+
|
|
321
|
+
const auth = useAuth()
|
|
322
|
+
const noti = useNotification()
|
|
323
|
+
const deleteLoader = useManualDeleteLoader()
|
|
324
|
+
|
|
325
|
+
type Mode = 'list' | 'view' | 'form'
|
|
326
|
+
|
|
327
|
+
const mode = ref<Mode>('list')
|
|
328
|
+
const selectedId = ref<string | null>(null)
|
|
329
|
+
const editing = ref<IManual | null>(null)
|
|
330
|
+
const confirmDelete = ref<IManual | null>(null)
|
|
331
|
+
|
|
332
|
+
const current = computed<IManual | null>(
|
|
333
|
+
() => props.manuals.find((m) => m.id === selectedId.value) ?? null,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
const canManage = (app: string) =>
|
|
337
|
+
auth.isSuperAdmin.value || auth.hasPermission(app as UserModule, Permission.SUPER)
|
|
338
|
+
|
|
339
|
+
const canManageAny = computed(() => {
|
|
340
|
+
if (auth.isSuperAdmin.value) return true
|
|
341
|
+
|
|
342
|
+
return props.manuals.some((m) => canManage(m.app))
|
|
343
|
+
|| (!!props.currentModule && auth.hasPermission(props.currentModule as UserModule, Permission.SUPER))
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const appLabel = (app: string) =>
|
|
347
|
+
UserModuleLabel[app as keyof typeof UserModuleLabel] || app
|
|
348
|
+
|
|
349
|
+
const logoOf = (app: string) =>
|
|
350
|
+
UserModuleLogo[app as keyof typeof UserModuleLogo]
|
|
351
|
+
|
|
352
|
+
const formatDate = (iso: string) => {
|
|
353
|
+
try {
|
|
354
|
+
return new Date(iso).toLocaleDateString('th-TH', {
|
|
355
|
+
day: 'numeric',
|
|
356
|
+
month: 'short',
|
|
357
|
+
year: 'numeric',
|
|
358
|
+
})
|
|
359
|
+
} catch {
|
|
360
|
+
return iso
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const groups = computed(() => {
|
|
365
|
+
const byApp = new Map<string, IManual[]>()
|
|
366
|
+
|
|
367
|
+
for (const manual of props.manuals) {
|
|
368
|
+
const list = byApp.get(manual.app) ?? []
|
|
369
|
+
|
|
370
|
+
list.push(manual)
|
|
371
|
+
byApp.set(manual.app, list)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return [...byApp.entries()]
|
|
375
|
+
.map(([app, items]) => ({
|
|
376
|
+
app,
|
|
377
|
+
label: appLabel(app),
|
|
378
|
+
items: [...items].sort((a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)),
|
|
379
|
+
}))
|
|
380
|
+
.sort((a, b) => {
|
|
381
|
+
if (a.app === props.currentModule) return -1
|
|
382
|
+
if (b.app === props.currentModule) return 1
|
|
383
|
+
|
|
384
|
+
return a.label.localeCompare(b.label)
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const headerTitle = computed(() => {
|
|
389
|
+
if (mode.value === 'view' && current.value) return current.value.name
|
|
390
|
+
if (mode.value === 'form') return editing.value ? 'แก้ไขคู่มือ' : 'เพิ่มคู่มือ'
|
|
391
|
+
|
|
392
|
+
return 'คู่มือการใช้งาน'
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
const headerSubtitle = computed(() => {
|
|
396
|
+
if (mode.value === 'view' && current.value) {
|
|
397
|
+
return `${appLabel(current.value.app)} · ปรับปรุง ${formatDate(current.value.updated_at)}`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (mode.value === 'form') return 'ไฟล์ PDF · เห็นเฉพาะผู้มีสิทธิ์ในแอพนั้น'
|
|
401
|
+
|
|
402
|
+
return 'เลือกคู่มือที่ต้องการเปิด'
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const openManual = (manual: IManual) => {
|
|
406
|
+
selectedId.value = manual.id
|
|
407
|
+
mode.value = 'view'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const startCreate = () => {
|
|
411
|
+
editing.value = null
|
|
412
|
+
mode.value = 'form'
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const startEdit = (manual: IManual) => {
|
|
416
|
+
editing.value = manual
|
|
417
|
+
mode.value = 'form'
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const backToList = () => {
|
|
421
|
+
mode.value = 'list'
|
|
422
|
+
editing.value = null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const onSaved = () => {
|
|
426
|
+
emit('refetch')
|
|
427
|
+
backToList()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const runDelete = () => {
|
|
431
|
+
const target = confirmDelete.value
|
|
432
|
+
if (!target) return
|
|
433
|
+
|
|
434
|
+
deleteLoader.run({
|
|
435
|
+
urlBind: {
|
|
436
|
+
id: target.id,
|
|
437
|
+
},
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
useWatchTrue(() => deleteLoader.status.value.isSuccess, () => {
|
|
442
|
+
confirmDelete.value = null
|
|
443
|
+
|
|
444
|
+
if (current.value && !props.manuals.some((m) => m.id === current.value?.id)) {
|
|
445
|
+
mode.value = 'list'
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
emit('refetch')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
useWatchTrue(() => deleteLoader.status.value.isError, () => {
|
|
452
|
+
noti.error({
|
|
453
|
+
title: 'ลบไม่สำเร็จ',
|
|
454
|
+
description: StringHelper.getError(deleteLoader.status.value.errorData, 'ลองใหม่อีกครั้ง'),
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// Always land on the picker when (re)opened.
|
|
459
|
+
useWatchTrue(() => props.isOpen, () => {
|
|
460
|
+
mode.value = 'list'
|
|
461
|
+
selectedId.value = null
|
|
462
|
+
editing.value = null
|
|
463
|
+
})
|
|
464
|
+
</script>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="fixed right-7 bottom-7 z-40 md:bottom-8.5 print:hidden">
|
|
3
|
+
<!-- click-away scrim -->
|
|
4
|
+
<div
|
|
5
|
+
v-if="isDialOpen"
|
|
6
|
+
class="fixed inset-0"
|
|
7
|
+
@click="isDialOpen = false"
|
|
8
|
+
/>
|
|
9
|
+
|
|
10
|
+
<div class="relative flex flex-col items-end gap-3.5">
|
|
11
|
+
<div
|
|
12
|
+
v-if="isDialOpen"
|
|
13
|
+
class="flex flex-col items-end gap-3.5 motion-safe:animate-[helpHubDialIn_160ms_ease-out]"
|
|
14
|
+
>
|
|
15
|
+
<HelpHubAction
|
|
16
|
+
label="ส่ง Feedback"
|
|
17
|
+
icon="ph:chat-circle-text"
|
|
18
|
+
@click="open('feedback')"
|
|
19
|
+
/>
|
|
20
|
+
<HelpHubAction
|
|
21
|
+
v-if="showManualAction"
|
|
22
|
+
label="คู่มือการใช้งาน"
|
|
23
|
+
icon="ph:book-open"
|
|
24
|
+
@click="open('manual')"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<button
|
|
29
|
+
class="flex size-10 cursor-pointer items-center justify-center rounded-full shadow-lg ring-2 transition-all duration-200 md:size-12"
|
|
30
|
+
:class="isDialOpen
|
|
31
|
+
? 'bg-gray-900 ring-gray-700 hover:bg-gray-800'
|
|
32
|
+
: 'bg-primary-600 ring-primary-200 hover:bg-primary-700 hover:scale-105 hover:shadow-xl'"
|
|
33
|
+
type="button"
|
|
34
|
+
:aria-expanded="isDialOpen"
|
|
35
|
+
aria-label="ความช่วยเหลือ — ส่ง Feedback หรือดูคู่มือการใช้งาน"
|
|
36
|
+
:title="isDialOpen ? 'ปิด' : 'ความช่วยเหลือ'"
|
|
37
|
+
@click="isDialOpen = !isDialOpen"
|
|
38
|
+
>
|
|
39
|
+
<Icon
|
|
40
|
+
:name="isDialOpen ? 'ph:x' : 'ph:question'"
|
|
41
|
+
class="size-6 text-white transition-transform duration-200 md:size-7"
|
|
42
|
+
:class="{ 'rotate-90': isDialOpen }"
|
|
43
|
+
/>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<FeedbackTicketForm
|
|
49
|
+
:is-open="panel === 'feedback'"
|
|
50
|
+
@update="(v) => { if (!v) panel = null }"
|
|
51
|
+
@success="panel = null"
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
<HelpHubManualViewer
|
|
55
|
+
:is-open="panel === 'manual'"
|
|
56
|
+
:manuals="manuals"
|
|
57
|
+
:current-module="currentModule"
|
|
58
|
+
:loading="manualList.status.value.isLoading"
|
|
59
|
+
@update="(v) => { if (!v) panel = null }"
|
|
60
|
+
@refetch="manualList.run()"
|
|
61
|
+
@feedback="open('feedback')"
|
|
62
|
+
/>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<script lang="ts" setup>
|
|
66
|
+
import { useManualListLoader } from '#layer/app/loaders/manual/manual'
|
|
67
|
+
import { Permission } from '#layer/app/composables/useAuth'
|
|
68
|
+
import type { UserModule } from '#layer/app/composables/useAuth'
|
|
69
|
+
|
|
70
|
+
type Panel = 'feedback' | 'manual' | null
|
|
71
|
+
|
|
72
|
+
const auth = useAuth()
|
|
73
|
+
const route = useRoute()
|
|
74
|
+
const currentModule = useCurrentModule()
|
|
75
|
+
const manualList = useManualListLoader()
|
|
76
|
+
|
|
77
|
+
const isDialOpen = ref(false)
|
|
78
|
+
const panel = ref<Panel>(null)
|
|
79
|
+
|
|
80
|
+
const manuals = computed(() => manualList.data.value ?? [])
|
|
81
|
+
|
|
82
|
+
const canManageAny = computed(() => {
|
|
83
|
+
if (auth.isSuperAdmin.value) return true
|
|
84
|
+
|
|
85
|
+
const mod = currentModule.value
|
|
86
|
+
|
|
87
|
+
return !!mod && auth.hasPermission(mod as UserModule, Permission.SUPER)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const showManualAction = computed(() => manuals.value.length > 0 || canManageAny.value)
|
|
91
|
+
|
|
92
|
+
const open = (next: Exclude<Panel, null>) => {
|
|
93
|
+
isDialOpen.value = false
|
|
94
|
+
panel.value = next
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
98
|
+
if (e.key === 'Escape' && isDialOpen.value) {
|
|
99
|
+
isDialOpen.value = false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onMounted(() => {
|
|
104
|
+
window.addEventListener('keydown', onKeydown)
|
|
105
|
+
|
|
106
|
+
if (auth.isAuthenticated.value && !manualList.status.value.isSuccess) {
|
|
107
|
+
manualList.run()
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
|
112
|
+
|
|
113
|
+
watch(() => route.path, () => {
|
|
114
|
+
isDialOpen.value = false
|
|
115
|
+
})
|
|
116
|
+
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { UserModule } from '#layer/app/composables/useAuth'
|
|
2
|
+
import { getModuleFromPath } from '#layer/app/constants/module-route'
|
|
3
|
+
|
|
4
|
+
/** The app/module the user is currently in, derived from the route. `null` on global pages. */
|
|
5
|
+
export const useCurrentModule = () => {
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
|
|
8
|
+
return computed<UserModule | null>(() => getModuleFromPath(route.path))
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { UserModule } from '#layer/app/composables/useAuth'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route-path prefix -> the app/module it belongs to.
|
|
5
|
+
* Order matters: longer / more specific prefixes must come first.
|
|
6
|
+
*/
|
|
7
|
+
export const MODULE_ROUTE_PREFIXES: readonly (readonly [string, UserModule])[] = [
|
|
8
|
+
['/timesheet-admin', UserModule.TIMESHEET],
|
|
9
|
+
['/timesheet', UserModule.TIMESHEET],
|
|
10
|
+
['/clockin-admin', UserModule.CHECKIN],
|
|
11
|
+
['/clockin', UserModule.CHECKIN],
|
|
12
|
+
['/pmo-setup', UserModule.PMO_SETUP],
|
|
13
|
+
['/pmo-presale-bidding', UserModule.PMO_PRESALE_BIDDING],
|
|
14
|
+
['/pmo-delivery', UserModule.PMO_DELIVERY],
|
|
15
|
+
['/pmo-warranty', UserModule.PMO_WARRANTY],
|
|
16
|
+
['/pmo', UserModule.PMO],
|
|
17
|
+
['/resource-allocation', UserModule.RESOURCE_ALLOCATION],
|
|
18
|
+
['/cost-sheet', UserModule.COSTSHEET],
|
|
19
|
+
['/e-factoring', UserModule.EFACTORING],
|
|
20
|
+
['/legal', UserModule.CONTRACT],
|
|
21
|
+
['/quotation', UserModule.QUOTATION],
|
|
22
|
+
['/newsletter', UserModule.NEWSLETTER],
|
|
23
|
+
['/announcement', UserModule.NEWSLETTER],
|
|
24
|
+
['/announcements', UserModule.NEWSLETTER],
|
|
25
|
+
['/todo', UserModule.TODO],
|
|
26
|
+
['/employee', UserModule.EMPLOYEE],
|
|
27
|
+
['/asset', UserModule.ASSET],
|
|
28
|
+
['/request-admin', UserModule.REQUEST],
|
|
29
|
+
['/request', UserModule.REQUEST],
|
|
30
|
+
['/resource', UserModule.RESOURCE],
|
|
31
|
+
['/lg', UserModule.LG],
|
|
32
|
+
['/evaluation', UserModule.EVALUATION],
|
|
33
|
+
['/loan', UserModule.LOAN],
|
|
34
|
+
['/purchase', UserModule.PURCHASE],
|
|
35
|
+
['/tor', UserModule.TOR],
|
|
36
|
+
['/support', UserModule.SUPPORT],
|
|
37
|
+
['/recruit', UserModule.RECRUIT],
|
|
38
|
+
['/docs-management', UserModule.DOCUMENT],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** Resolve the current app/module from a route path, or `null` for global pages. */
|
|
42
|
+
export const getModuleFromPath = (path: string): UserModule | null => {
|
|
43
|
+
const hit = MODULE_ROUTE_PREFIXES.find(
|
|
44
|
+
([prefix]) => path === prefix || path.startsWith(`${prefix}/`),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return hit ? hit[1] : null
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface IManualFile {
|
|
2
|
+
id: string
|
|
3
|
+
name: string
|
|
4
|
+
url: string
|
|
5
|
+
path: string
|
|
6
|
+
size: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IManual {
|
|
10
|
+
id: string
|
|
11
|
+
app: string
|
|
12
|
+
name: string
|
|
13
|
+
description: string | null
|
|
14
|
+
file_id: string
|
|
15
|
+
sort_order: number
|
|
16
|
+
file: IManualFile | null
|
|
17
|
+
created_at: string
|
|
18
|
+
updated_at: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** All manuals the signed-in user is allowed to see (filtered server-side). */
|
|
22
|
+
export const useManualListLoader = () => {
|
|
23
|
+
const options = useRequestOptions()
|
|
24
|
+
|
|
25
|
+
return useObjectLoader<IManual[]>({
|
|
26
|
+
method: 'GET',
|
|
27
|
+
url: '/manuals',
|
|
28
|
+
getRequestOptions: options.auth,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const useManualAddLoader = () => {
|
|
33
|
+
const options = useRequestOptions()
|
|
34
|
+
|
|
35
|
+
return useObjectLoader<IManual>({
|
|
36
|
+
method: 'POST',
|
|
37
|
+
url: '/manuals',
|
|
38
|
+
getRequestOptions: options.auth,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const useManualUpdateLoader = () => {
|
|
43
|
+
const options = useRequestOptions()
|
|
44
|
+
|
|
45
|
+
return useObjectLoader<IManual>({
|
|
46
|
+
method: 'PUT',
|
|
47
|
+
url: '/manuals/:id',
|
|
48
|
+
getRequestOptions: options.auth,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const useManualDeleteLoader = () => {
|
|
53
|
+
const options = useRequestOptions()
|
|
54
|
+
|
|
55
|
+
return useObjectLoader({
|
|
56
|
+
method: 'DELETE',
|
|
57
|
+
url: '/manuals/:id',
|
|
58
|
+
getRequestOptions: options.auth,
|
|
59
|
+
})
|
|
60
|
+
}
|