@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +122 -2
  3. package/package.json +44 -4
  4. package/src/components/ai/AppAssistantDraft.vue +120 -0
  5. package/src/components/ai/AppAssistantWidget.vue +245 -0
  6. package/src/components/ai/AppCitedAnswer.vue +86 -0
  7. package/src/components/approvals/AppApprovalInbox.vue +265 -0
  8. package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
  9. package/src/components/chat/AppChatBubble.vue +324 -0
  10. package/src/components/chat/AppChatList.vue +249 -0
  11. package/src/components/chat/AppChatPanel.vue +315 -0
  12. package/src/components/chat/AppChatWidget.vue +126 -0
  13. package/src/components/communications/AppChannelBadge.vue +47 -0
  14. package/src/components/communications/AppConversationThread.vue +439 -0
  15. package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
  16. package/src/components/customFields/CustomFieldInput.vue +422 -0
  17. package/src/components/customFields/CustomFieldValue.vue +129 -0
  18. package/src/components/customFields/CustomFieldsSection.vue +65 -0
  19. package/src/components/customFields/fieldTypes.js +23 -0
  20. package/src/components/dashboard/AppCustomWidget.vue +138 -0
  21. package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
  22. package/src/components/dashboard/AppDashboardGrid.vue +200 -0
  23. package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
  24. package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
  25. package/src/components/dataTools/AppImportExport.vue +724 -0
  26. package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
  27. package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
  28. package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
  29. package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
  30. package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
  31. package/src/components/kpi/AppKpiAppraisals.vue +733 -0
  32. package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
  33. package/src/components/kpi/AppKpiEntryModal.vue +122 -0
  34. package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
  35. package/src/components/kpi/AppKpiMyCard.vue +134 -0
  36. package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
  37. package/src/components/kpi/AppKpiReportTable.vue +214 -0
  38. package/src/components/kpi/AppKpiScorecard.vue +242 -0
  39. package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
  40. package/src/components/kpi/AppKpiTeam.vue +221 -0
  41. package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
  42. package/src/components/kpi/AppKpiWorkspace.vue +479 -0
  43. package/src/components/layout/AppGlobalSearch.vue +149 -0
  44. package/src/components/layout/AppLanguagePopup.vue +94 -0
  45. package/src/components/layout/AppLauncherPopup.vue +147 -0
  46. package/src/components/layout/AppLegalConsentModal.vue +484 -0
  47. package/src/components/layout/AppNavbar.vue +402 -0
  48. package/src/components/layout/AppNotificationContent.vue +173 -0
  49. package/src/components/layout/AppNotificationDrawer.vue +98 -0
  50. package/src/components/layout/AppNotificationPopup.vue +67 -0
  51. package/src/components/layout/AppPlatformBanners.vue +192 -0
  52. package/src/components/layout/AppSidebar.vue +226 -0
  53. package/src/components/layout/AppSidebarNav.vue +119 -0
  54. package/src/components/layout/AppUserMenu.vue +89 -0
  55. package/src/components/layout/AppUserMenuContent.vue +59 -0
  56. package/src/composables/useKpi.js +232 -0
  57. package/src/composables/useLgViewport.js +25 -0
  58. package/src/composables/useNavigation.js +177 -0
  59. package/src/layouts/AppLayout.vue +207 -0
  60. package/src/locales/en.json +1362 -0
  61. package/src/locales/id.json +1396 -0
  62. package/src/pages/auth/AccountBlocked.vue +55 -0
  63. package/src/pages/auth/IdentityRedirect.vue +37 -0
  64. package/src/pages/profile/Layout.vue +210 -0
  65. package/src/pages/profile/partials/AccountSection.vue +139 -0
  66. package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
  67. package/src/pages/profile/partials/DevicesCard.vue +222 -0
  68. package/src/pages/profile/partials/PreferencesSection.vue +87 -0
  69. package/src/pages/profile/partials/SecuritySection.vue +93 -0
  70. package/src/pages/profile/partials/SessionsCard.vue +418 -0
  71. package/src/pages/profile/partials/SessionsSection.vue +58 -0
  72. package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
  73. package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
  74. package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
  75. package/src/shellSetup.js +46 -0
package/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ Copyright (c) 2026 PT Init Solusi Digital. All rights reserved.
2
+
3
+ This software and its source code are proprietary to PT Init Solusi Digital.
4
+
5
+ The source code is made available for viewing and reference only. No license
6
+ is granted to use, copy, modify, merge, publish, distribute, sublicense, or
7
+ sell this software, in whole or in part, or to create derivative works from
8
+ it, without prior written permission from PT Init Solusi Digital.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
13
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
14
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # @initsolusidigital/worksuite-shell
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The signed-in shell shared by the Init Worksuite web apps: the page layout with its sidebar and
4
+ header, the user menu, notifications, the app launcher, global search, the floating chat and
5
+ assistant widgets, the profile pages, and the screens several apps render the same way (approval
6
+ inbox, dashboard grid, import and export, custom fields, KPI and HR metrics). The package ships raw
7
+ `.js` and `.vue` source, and each app compiles it in its own Vite build.
8
+
9
+ The source is published so it can be read. Using it requires written permission from
10
+ PT Init Solusi Digital; the terms are in the `LICENSE` file included in the package.
11
+
12
+ ## Contents
13
+
14
+ The files mirror the `src/` folder of an app, so `src/layouts/AppLayout.vue` in the package is the
15
+ file an app used to keep at `src/layouts/AppLayout.vue`.
16
+
17
+ | Folder | Files |
18
+ |---|---|
19
+ | `layouts/` | `AppLayout.vue` (sidebar, header, banners, breadcrumb row, floating widgets, toasts) |
20
+ | `components/layout/` | `AppSidebar`, `AppSidebarNav`, `AppNavbar`, `AppUserMenu`, `AppUserMenuContent`, `AppNotificationPopup`, `AppNotificationDrawer`, `AppNotificationContent`, `AppLauncherPopup`, `AppLanguagePopup`, `AppGlobalSearch`, `AppPlatformBanners`, `AppLegalConsentModal` |
21
+ | `components/chat/` | the floating chat widget and its bubble, list and panel |
22
+ | `components/ai/` | the assistant widget, its draft confirmation screen, cited answers |
23
+ | `components/approvals/` | `AppApprovalInbox`, `AppApprovalSubjectModal` |
24
+ | `components/communications/` | `AppConversationThread`, `AppChannelBadge` |
25
+ | `components/dashboard/` | `AppDashboardGrid`, `AppDashboardToolbar`, `AppCustomWidget`, `AppCustomWidgetForm`, `AppSubAppsCatalog` |
26
+ | `components/dataTools/` | `AppImportExport`, `AppImportSchemaGuide` |
27
+ | `components/customFields/` | custom field input, value, section and filter panel |
28
+ | `components/kpi/`, `composables/useKpi.js` | KPI workspace, scorecards, entry and target forms, appraisals |
29
+ | `components/hrMetrics/` | HR metrics dashboard, per-employee list, stat card |
30
+ | `composables/` | `useNavigation.js` (menu filtered by module permission) |
31
+ | `pages/profile/` | `Layout.vue` and the account, security, sessions, two-factor, preferences, support access and approval delegation sections |
32
+ | `pages/auth/` | `AccountBlocked.vue`, `IdentityRedirect.vue` (the local `/login` route that forwards to the sign-in app) |
33
+ | `locales/` | `id.json`, `en.json` (see Translations) |
34
+
35
+ ## Requirements
36
+
37
+ - `@initsolusidigital/worksuite-core`, `@initsolusidigital/worksuite-ui` and
38
+ `@initsolusidigital/worksuite-tooling` at the same exact version as this package. Shell files
39
+ import the core stores and composables and the ui components.
40
+ - Vue 3.5, vue-router 4, vue-i18n 11, `@lucide/vue`, `@iconify/vue` and
41
+ `@iconify-icons/simple-icons`.
42
+ - Tailwind CSS v4. The app's Tailwind build must scan this package.
43
+ - Optional libraries, needed only by the files that use them: `grid-layout-plus` (dashboard grid),
44
+ `leaflet` (location messages in chat), `qrcode` (two-factor enrolment). The custom dashboard
45
+ widget and the HR metrics charts render `<apexchart>`, so an app that shows them registers
46
+ `vue3-apexcharts` globally.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ npm install --save-exact @initsolusidigital/worksuite-shell @initsolusidigital/worksuite-core @initsolusidigital/worksuite-ui @initsolusidigital/worksuite-tooling
52
+ ```
53
+
54
+ 1. Set up `worksuite-core` first (see its README). The shell builds on its configuration.
55
+
56
+ 2. Add a `@source` line for this package to `src/style.css`, next to the core and ui lines:
57
+
58
+ ```css
59
+ @source "../node_modules/@initsolusidigital/worksuite-shell/src";
60
+ ```
61
+
62
+ 3. Call `configureShell()` in `src/main.js`, after `configureWorksuite()`:
63
+
64
+ ```js
65
+ import { configureWorksuite } from '@initsolusidigital/worksuite-core/setup.js'
66
+ import { configureShell } from '@initsolusidigital/worksuite-shell/shellSetup.js'
67
+ import { navigation } from '@/data/navigation'
68
+ import { useBreadcrumbs } from '@/composables/useAppBreadcrumbs'
69
+
70
+ configureWorksuite({ router, messages })
71
+ configureShell({ navigation, useBreadcrumbs })
72
+ ```
73
+
74
+ | Option | Required | Meaning |
75
+ |---|---|---|
76
+ | `navigation` | yes | The app's sidebar menu: `[{ labelKey?, position?, items: [...] }]`. |
77
+ | `useBreadcrumbs` | yes | The app's breadcrumb composable, returning `{ breadcrumbs }`. |
78
+ | `navigationBadges` | no | `{ badgeKey: () => ref }`, one factory per menu badge. |
79
+ | `tenantSettingsEntryRoute` | no | `(can) => routeName \| null`. Without it the tenant settings icon is not shown in the header. |
80
+ | `onLogout` | no | `({ router, redirect }) => void`, called after the session ends from the user menu. `redirect()` is the default behaviour: the sign-in app, or the app's `login` route when its address is unknown. |
81
+
82
+ Shell files read this configuration when they render, never when they are imported. Rendering a
83
+ shell component before `configureShell()` throws an error that names the missing call.
84
+
85
+ 4. Delete the app's copies of the files that now come from the package.
86
+
87
+ 5. Run `npx worksuite-kit-check` from the app root.
88
+
89
+ ## Importing
90
+
91
+ As with core, apps keep their existing imports. The plugin redirects `@/layouts/AppLayout.vue`,
92
+ `@/components/layout/AppNavbar.vue`, `@/composables/useNavigation` and every other path that exists
93
+ in this package to the package file, and paths the package does not have still resolve to the
94
+ app's own files.
95
+
96
+ What stays in the app: `data/navigation.js`, the breadcrumb map and composable, route definitions
97
+ (including the `/profile` child routes), dashboard widget registries and the widgets themselves.
98
+
99
+ The profile layout only links the sections whose routes the app has registered, so an app can
100
+ leave a section out by not registering its route.
101
+
102
+ ## Translations
103
+
104
+ The package ships its own dictionary in `locales/id.json` and `locales/en.json` and registers it
105
+ with the core i18n instance when `shellSetup.js` loads. The app's dictionary is merged on top, and
106
+ the app's keys win, so the rule from core applies here too: an app key with the same path as a
107
+ package key is an override that blocks fixes made in the package. `worksuite-kit-check` lists
108
+ those overrides as warnings.
109
+
110
+ ## What worksuite-kit-check reports
111
+
112
+ For apps that install this package, the check fails when:
113
+
114
+ - `src/main.js` does not call `configureShell()`. The sidebar then has no menu, the breadcrumb row
115
+ is empty, and the shell dictionary is never merged;
116
+ - the package is installed without `worksuite-core`;
117
+ - `src/style.css` has no `@source` line for this package;
118
+ - a copy of a package file is still in the app's `src/`.
119
+
120
+ ## Versioning
121
+
122
+ All `@initsolusidigital/worksuite-*` packages share one version number. Install them at the same
123
+ exact version; mixing versions is not supported.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@initsolusidigital/worksuite-shell",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Signed-in app shell for the Init Worksuite web apps: layout, navigation, user menu, notifications, chat widget, profile pages and shared dashboard widgets, shipped as raw source",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ "./*": "./src/*",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "sideEffects": [
19
+ "*.vue",
20
+ "*.css"
21
+ ],
22
+ "peerDependencies": {
23
+ "@iconify-icons/simple-icons": "^1.2.74",
24
+ "@iconify/vue": "^5.0.1",
25
+ "@initsolusidigital/worksuite-core": ">=0.3.0 <1.0.0",
26
+ "@initsolusidigital/worksuite-ui": ">=0.3.0 <1.0.0",
27
+ "@lucide/vue": "^1.21.0",
28
+ "grid-layout-plus": "^1.1.1",
29
+ "leaflet": "^1.9.4",
30
+ "qrcode": "^1.5.4",
31
+ "vue": "^3.5.34",
32
+ "vue-i18n": "^11.4.6",
33
+ "vue-router": "^4.6.4"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "grid-layout-plus": {
37
+ "optional": true
38
+ },
39
+ "leaflet": {
40
+ "optional": true
41
+ },
42
+ "qrcode": {
43
+ "optional": true
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,120 @@
1
+ <script setup>
2
+ // Layar konfirmasi draf asisten. Asisten TIDAK pernah menyimpan sendiri: ia
3
+ // menyiapkan muatan, pengguna melengkapi `needs` (jenis tugas & entitas tujuan dikonfigurasi per
4
+ // tenant, jadi tak bisa ditebak model), lalu layar mengirimnya ke endpoint biasa — dengan
5
+ // gerbang izin yang sama seperti membuat tugas dari layar CRM.
6
+ import { ref, computed, watch, onMounted } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+ import { assistantApi } from '@initsolusidigital/worksuite-core/api/endpoints/assistant.js'
9
+ import { useFormSubmit } from '@initsolusidigital/worksuite-core/composables/useFormSubmit.js'
10
+ import AppAlert from '@initsolusidigital/worksuite-ui/AppAlert.vue'
11
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
12
+ import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
13
+ import AppCombobox from '@initsolusidigital/worksuite-ui/AppCombobox.vue'
14
+
15
+ const props = defineProps({
16
+ draft: { type: Object, required: true }, // { type: 'crm_task', endpoint, payload, needs }
17
+ })
18
+ const emit = defineEmits(['done', 'cancel'])
19
+
20
+ const { t, te } = useI18n()
21
+ const { fieldErrors, formError, saving, submit } = useFormSubmit()
22
+
23
+ const toLocalInput = (v) => {
24
+ if (!v) return ''
25
+ const d = new Date(String(v).replace(' ', 'T'))
26
+ if (Number.isNaN(d.getTime())) return ''
27
+ const p = (n) => String(n).padStart(2, '0')
28
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
29
+ }
30
+
31
+ const form = ref({
32
+ title: props.draft.payload?.title ?? '',
33
+ due_at: toLocalInput(props.draft.payload?.due_at),
34
+ type: null,
35
+ taskable_type: 'opportunities',
36
+ taskable_id: null,
37
+ })
38
+
39
+ const typeOptions = ref([])
40
+ const taskableOptions = ref([])
41
+ const loadingTaskables = ref(false)
42
+ const taskableTypeOptions = computed(() => ['opportunities', 'customers', 'contacts'].map((v) => ({ value: v, label: t(`ai.assistant.draft.taskable.${v}`) })))
43
+
44
+ async function loadTaskables(search = '') {
45
+ loadingTaskables.value = true
46
+ try {
47
+ const { data } = await assistantApi.taskables(form.value.taskable_type, search ? { search } : {})
48
+ const rows = data?.data?.data ?? data?.data ?? []
49
+ taskableOptions.value = rows.map((r) => ({
50
+ value: r.id,
51
+ label: r.name ?? r.customer_company_name ?? ([r.first_name, r.last_name].filter(Boolean).join(' ') || r.id),
52
+ }))
53
+ } catch { taskableOptions.value = [] } finally {
54
+ loadingTaskables.value = false
55
+ }
56
+ }
57
+ watch(() => form.value.taskable_type, () => { form.value.taskable_id = null; loadTaskables() })
58
+
59
+ onMounted(async () => {
60
+ try {
61
+ const { data } = await assistantApi.taskTypes()
62
+ const rows = data?.data?.data ?? data?.data ?? []
63
+ typeOptions.value = rows.map((r) => ({
64
+ value: r.key,
65
+ label: r.is_system && te(`crm.stages.taskType.${r.key}`) ? t(`crm.stages.taskType.${r.key}`) : (r.name ?? r.key),
66
+ }))
67
+ form.value.type = typeOptions.value[0]?.value ?? null
68
+ } catch { typeOptions.value = [] }
69
+ loadTaskables()
70
+ })
71
+
72
+ function save() {
73
+ const f = form.value
74
+ const payload = {
75
+ ...props.draft.payload,
76
+ title: f.title.trim(),
77
+ due_at: f.due_at ? `${f.due_at.replace('T', ' ')}:00` : null,
78
+ type: f.type,
79
+ taskable_type: f.taskable_type,
80
+ taskable_id: f.taskable_id,
81
+ }
82
+ submit(() => assistantApi.submitDraft(props.draft.endpoint, payload), {
83
+ require: ['title', 'type', 'taskable_id'],
84
+ requireIn: f,
85
+ successKey: 'ai.assistant.draft.saved',
86
+ errorKey: 'ai.assistant.draft.saveError',
87
+ onSuccess: () => emit('done'),
88
+ })
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <div class="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/60 dark:bg-blue-900/20 p-3 space-y-3" data-assistant-draft>
94
+ <p class="text-xs font-medium text-blue-800 dark:text-blue-300">{{ t('ai.assistant.draft.title') }}</p>
95
+ <AppAlert v-if="formError" variant="error" :message="formError" />
96
+ <AppInput v-model="form.title" :label="t('ai.assistant.draft.fieldTitle')" :error="fieldErrors.title" required />
97
+ <div class="grid grid-cols-1 gap-3">
98
+ <AppCombobox v-model="form.type" :options="typeOptions" :label="t('ai.assistant.draft.fieldType')" :error="fieldErrors.type" hide-clear required />
99
+ <div>
100
+ <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="assistant-due">{{ t('ai.assistant.draft.fieldDue') }}</label>
101
+ <input id="assistant-due" v-model="form.due_at" type="datetime-local" class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
102
+ </div>
103
+ <AppCombobox v-model="form.taskable_type" :options="taskableTypeOptions" :label="t('ai.assistant.draft.fieldTaskableType')" hide-clear />
104
+ <AppCombobox
105
+ v-model="form.taskable_id"
106
+ :options="taskableOptions"
107
+ :loading="loadingTaskables"
108
+ :filter-results="false"
109
+ :label="t(`ai.assistant.draft.taskable.${form.taskable_type}`)"
110
+ :error="fieldErrors.taskable_id"
111
+ required
112
+ @search="loadTaskables"
113
+ />
114
+ </div>
115
+ <div class="flex justify-end gap-2">
116
+ <AppButton size="sm" variant="secondary" @click="emit('cancel')">{{ t('common.cancel') }}</AppButton>
117
+ <AppButton size="sm" variant="primary" :loading="saving" data-assistant-draft-save @click="save">{{ t('ai.assistant.draft.save') }}</AppButton>
118
+ </div>
119
+ </div>
120
+ </template>
@@ -0,0 +1,245 @@
1
+ <script setup>
2
+ // Widget Asisten AI — tombol melayang DI ATAS tombol chat (satu kolom kanan),
3
+ // semua app staf. Posisinya ikut `--fab-lift` dari AppLayout supaya naik bersama tombol chat saat
4
+ // footer terlihat.
5
+ // Tampil hanya bila pengguna boleh MENJALANKAN asisten (`ai_assistant,can_create`) dan tenant
6
+ // beradd-on AI; tool yang ditawarkan sudah disaring backend menurut izin modul masing-masing.
7
+ // Konteks `{app, path}` ikut dikirim supaya pertanyaan "di halaman ini" bisa dijawab relevan.
8
+ //
9
+ // Riwayat percakapan hanya hidup di memori tab: backend tak menyimpannya, dan menyimpannya di
10
+ // browser akan membawa potongan data tenant ke perangkat yang mungkin dipakai bersama.
11
+ import { ref, computed, nextTick, watch } from 'vue'
12
+ import { useRoute } from 'vue-router'
13
+ import { useI18n } from 'vue-i18n'
14
+ import { Sparkles, X, Send, ExternalLink } from '@lucide/vue'
15
+ import { useAuthStore } from '@initsolusidigital/worksuite-core/stores/auth.js'
16
+ import { useChat } from '@initsolusidigital/worksuite-core/composables/useChat.js'
17
+ import { useCrossAppLink } from '@initsolusidigital/worksuite-core/composables/useCrossAppLink.js'
18
+ import { assistantApi } from '@initsolusidigital/worksuite-core/api/endpoints/assistant.js'
19
+ import { normalizeApiError } from '@initsolusidigital/worksuite-core/api/errors.js'
20
+ import { aiErrorKey, creditSummary } from '@initsolusidigital/worksuite-core/utils/aiError.js'
21
+ import AppSpinner from '@initsolusidigital/worksuite-ui/AppSpinner.vue'
22
+ import AppCitedAnswer from './AppCitedAnswer.vue'
23
+ import AppAssistantDraft from './AppAssistantDraft.vue'
24
+
25
+ const { t, te, locale } = useI18n()
26
+ const route = useRoute()
27
+ const authStore = useAuthStore()
28
+ const { openTarget } = useCrossAppLink()
29
+
30
+ const companyId = computed(() => authStore.user?.company?.id ?? authStore.user?.company_id)
31
+ // Add-on AI dibaca dari `entitled_addons` /auth/me; absen (null) = jangan sembunyikan — gerbang
32
+ // sesungguhnya 403 `ai_addon_not_active` di backend, yang ditampilkan sebagai kalimat.
33
+ const addonOk = computed(() => {
34
+ const list = authStore.sessionMeta?.entitled_addons
35
+ return !Array.isArray(list) || list.includes('ai_credits')
36
+ })
37
+
38
+ const open = ref(false)
39
+ const tools = ref(null)
40
+ const loadingTools = ref(false)
41
+ const turns = ref([]) // [{ q, res?, error?, pending, draftDone? }]
42
+ const question = ref('')
43
+ const sending = ref(false)
44
+ const scrollEl = ref(null)
45
+
46
+ async function loadTools() {
47
+ if (tools.value || loadingTools.value || !companyId.value) return
48
+ loadingTools.value = true
49
+ try {
50
+ const { data } = await assistantApi.tools(companyId.value)
51
+ tools.value = data?.data ?? []
52
+ } catch { tools.value = [] } finally {
53
+ loadingTools.value = false
54
+ }
55
+ }
56
+ watch(open, (v) => { if (v) loadTools() })
57
+
58
+ // Panel asisten & panel chat berlabuh di titik yang sama (kiri tumpukan tombol), jadi hanya satu
59
+ // yang boleh terbuka: tanpa ini panel yang dirender belakangan menutupi yang lain sepenuhnya.
60
+ const { isOpen: chatOpen, closeWidget: closeChat } = useChat()
61
+ watch(open, (v) => { if (v) closeChat() })
62
+ watch(chatOpen, (v) => { if (v) open.value = false })
63
+
64
+ const toolLabel = (k) => (te(`ai.assistant.tool.${k}`) ? t(`ai.assistant.tool.${k}`) : k)
65
+ const suggestions = computed(() => (tools.value ?? [])
66
+ .filter((x) => te(`ai.assistant.sample.${x.key}`))
67
+ .slice(0, 6)
68
+ .map((x) => ({ key: x.key, text: t(`ai.assistant.sample.${x.key}`) })))
69
+
70
+ async function scrollDown() {
71
+ await nextTick()
72
+ scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight })
73
+ }
74
+
75
+ async function ask(text) {
76
+ const q = (text ?? question.value).trim()
77
+ if (!q || sending.value) return
78
+ question.value = ''
79
+ const turn = { q, pending: true }
80
+ turns.value.push(turn)
81
+ sending.value = true
82
+ scrollDown()
83
+ try {
84
+ const { data } = await assistantApi.ask(companyId.value, q, { app: import.meta.env.VITE_APP_KEY, path: route.fullPath })
85
+ turn.res = data?.data ?? null
86
+ } catch (err) {
87
+ const e = normalizeApiError(err)
88
+ turn.error = aiErrorKey(e) ? t(aiErrorKey(e)) : e.status === 429 ? t('ai.assistant.tooFast') : t('ai.assistant.error')
89
+ } finally {
90
+ turn.pending = false
91
+ sending.value = false
92
+ turns.value = [...turns.value]
93
+ scrollDown()
94
+ }
95
+ }
96
+ function onKey(e) {
97
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask() }
98
+ }
99
+
100
+ // ── Data tool: daftar yang bisa diklik bila tujuannya diketahui ──
101
+ function fmtDate(v) {
102
+ if (!v) return ''
103
+ const d = new Date(String(v).length === 10 ? `${v}T00:00:00` : String(v).replace(' ', 'T'))
104
+ return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleDateString(locale.value === 'id' ? 'id-ID' : 'en-US', { day: 'numeric', month: 'short' })
105
+ }
106
+ const money = (n) => new Intl.NumberFormat(locale.value === 'id' ? 'id-ID' : 'en-US', { maximumFractionDigits: 0 }).format(Number(n) || 0)
107
+
108
+ function listOf(res) {
109
+ const d = res?.data ?? {}
110
+ switch (res?.tool) {
111
+ case 'my_crm_tasks':
112
+ return (d.tasks ?? []).map((x) => ({ id: x.id, title: x.title, meta: fmtDate(x.due_at), alert: x.overdue, target: { target_app: 'crm', target_path: '/opportunities/tasks' } }))
113
+ case 'my_open_tickets':
114
+ return (d.assigned_to_me ?? []).map((x) => ({ id: x.id, title: `${x.ticket_number} · ${x.subject}`, meta: fmtDate(x.resolution_due_at), alert: x.resolution_breached, target: { target_app: 'crm', target_path: `/tickets/${x.id}` } }))
115
+ case 'my_pipeline':
116
+ return (d.top ?? []).map((x) => ({ id: x.id, title: x.name, meta: money(x.expected_value_base), target: { target_app: 'crm', target_path: `/opportunities?open=${x.id}` } }))
117
+ case 'overdue_invoices':
118
+ return (d.largest ?? []).map((x) => ({ id: x.id, title: x.invoice_number, meta: `${money(x.total_amount)} · ${fmtDate(x.due_date)}`, alert: true, target: { target_app: 'finance', target_path: `/transactions/invoices?open=${x.id}` } }))
119
+ case 'my_project_tasks':
120
+ return (d.tasks ?? []).map((x) => ({ id: x.id, title: x.title, meta: fmtDate(x.due_date), target: { target_app: 'project', target_path: `/my-tasks?open=${x.id}` } }))
121
+ case 'pending_leave_requests':
122
+ return (d.oldest ?? []).map((x) => ({ id: x.id, title: `${fmtDate(x.start_date)} – ${fmtDate(x.end_date)}`, meta: t('ai.assistant.days', { n: x.total_days }) }))
123
+ default:
124
+ return []
125
+ }
126
+ }
127
+ function openItem(it) {
128
+ if (it.target) openTarget(it.target)
129
+ }
130
+ </script>
131
+
132
+ <template>
133
+ <div v-if="addonOk">
134
+ <button
135
+ type="button"
136
+ class="fixed bottom-[calc(5.75rem+var(--fab-lift,0px))] right-5 sm:right-7 z-[46] w-12 h-12 rounded-full bg-violet-600 hover:bg-violet-700 text-white shadow-lg flex items-center justify-center transition-colors"
137
+ :aria-label="t('ai.assistant.title')"
138
+ :aria-expanded="open"
139
+ data-assistant-toggle
140
+ @click="open = !open"
141
+ >
142
+ <X v-if="open" class="w-5 h-5" />
143
+ <Sparkles v-else class="w-5 h-5" />
144
+ </button>
145
+
146
+ <div
147
+ v-if="open"
148
+ 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-h-[calc(100vh-16rem-var(--fab-lift,0px))] sm:max-h-[calc(100vh-8rem-var(--fab-lift,0px))] flex flex-col rounded-2xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-2xl overflow-hidden"
149
+ role="dialog"
150
+ :aria-label="t('ai.assistant.title')"
151
+ data-assistant-panel
152
+ >
153
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-violet-600 text-white">
154
+ <p class="text-sm font-semibold flex items-center gap-1.5"><Sparkles class="w-4 h-4" />{{ t('ai.assistant.title') }}</p>
155
+ <p class="text-[11px] text-violet-100">{{ t('ai.assistant.subtitle') }}</p>
156
+ </div>
157
+
158
+ <div ref="scrollEl" class="flex-1 overflow-y-auto scrollbar-rounded p-3 space-y-3 bg-gray-50 dark:bg-gray-900/40">
159
+ <div v-if="!turns.length" class="space-y-2">
160
+ <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('ai.assistant.intro') }}</p>
161
+ <div v-if="loadingTools" class="flex justify-center py-3"><AppSpinner size="sm" /></div>
162
+ <div v-else class="flex flex-wrap gap-1.5">
163
+ <button
164
+ v-for="s in suggestions"
165
+ :key="s.key"
166
+ type="button"
167
+ class="px-2.5 py-1 rounded-full text-xs border border-violet-200 dark:border-violet-700 text-violet-700 dark:text-violet-300 bg-white dark:bg-gray-800 hover:bg-violet-50 dark:hover:bg-violet-900/30"
168
+ :data-assistant-suggestion="s.key"
169
+ @click="ask(s.text)"
170
+ >{{ s.text }}</button>
171
+ </div>
172
+ <p v-if="tools && !tools.length" class="text-xs text-gray-400">{{ t('ai.assistant.noTools') }}</p>
173
+ </div>
174
+
175
+ <div v-for="(turn, i) in turns" :key="i" class="space-y-2" :data-assistant-turn="i">
176
+ <div class="flex justify-end">
177
+ <p class="max-w-[85%] rounded-2xl rounded-tr-sm bg-violet-600 text-white px-3 py-2 text-sm whitespace-pre-line">{{ turn.q }}</p>
178
+ </div>
179
+ <div class="flex justify-start">
180
+ <div class="max-w-[92%] rounded-2xl rounded-tl-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-3 py-2 space-y-2">
181
+ <AppSpinner v-if="turn.pending" size="sm" />
182
+ <p v-else-if="turn.error" class="text-sm text-red-600 dark:text-red-400">{{ turn.error }}</p>
183
+ <template v-else-if="turn.res">
184
+ <AppCitedAnswer v-if="turn.res.citations?.length" :answer="turn.res.answer ?? ''" :citations="turn.res.citations" />
185
+ <p v-else class="text-sm text-gray-800 dark:text-gray-100 whitespace-pre-line">{{ turn.res.answer }}</p>
186
+
187
+ <ul v-if="listOf(turn.res).length" class="space-y-1">
188
+ <li v-for="it in listOf(turn.res)" :key="it.id">
189
+ <button
190
+ type="button"
191
+ class="w-full text-left flex items-start gap-2 text-xs rounded-md px-2 py-1 hover:bg-gray-50 dark:hover:bg-gray-700/40 disabled:cursor-default"
192
+ :disabled="!it.target"
193
+ @click="openItem(it)"
194
+ >
195
+ <span class="flex-1 min-w-0 text-gray-800 dark:text-gray-200 truncate">{{ it.title }}</span>
196
+ <span class="shrink-0" :class="it.alert ? 'text-red-600 dark:text-red-400' : 'text-gray-400'">{{ it.meta }}</span>
197
+ <ExternalLink v-if="it.target" class="w-3 h-3 shrink-0 text-gray-400 mt-0.5" />
198
+ </button>
199
+ </li>
200
+ </ul>
201
+
202
+ <div v-if="turn.res.tool === 'unsupported' && turn.res.data?.available_tools?.length" class="text-xs text-gray-500 dark:text-gray-400">
203
+ <p class="mb-1">{{ t('ai.assistant.canHelpWith') }}</p>
204
+ <ul class="list-disc pl-4 space-y-0.5">
205
+ <li v-for="k in turn.res.data.available_tools" :key="k">{{ toolLabel(k) }}</li>
206
+ </ul>
207
+ </div>
208
+
209
+ <AppAssistantDraft
210
+ v-if="turn.res.draft && !turn.draftDone"
211
+ :draft="turn.res.draft"
212
+ @done="turn.draftDone = 'saved'; turns = [...turns]"
213
+ @cancel="turn.draftDone = 'cancelled'; turns = [...turns]"
214
+ />
215
+ <p v-else-if="turn.draftDone === 'saved'" class="text-xs text-green-600 dark:text-green-400">{{ t('ai.assistant.draft.saved') }}</p>
216
+
217
+ <p class="text-[10px] text-gray-400">{{ creditSummary(t, turn.res) }}</p>
218
+ </template>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <div class="p-3 border-t border-gray-200 dark:border-gray-700 flex items-end gap-2">
225
+ <textarea
226
+ v-model="question"
227
+ rows="2"
228
+ :placeholder="t('ai.assistant.placeholder')"
229
+ :aria-label="t('ai.assistant.placeholder')"
230
+ class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-violet-500 resize-none"
231
+ data-assistant-input
232
+ @keydown="onKey"
233
+ ></textarea>
234
+ <button
235
+ type="button"
236
+ class="p-2.5 rounded-lg bg-violet-600 hover:bg-violet-700 text-white disabled:opacity-50"
237
+ :disabled="!question.trim() || sending"
238
+ :aria-label="t('ai.assistant.send')"
239
+ data-assistant-send
240
+ @click="ask()"
241
+ ><Send class="w-4 h-4" /></button>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup>
2
+ // Jawaban AI bersitasi — dipakai tanya-jawab KB, widget asisten, dan sumber
3
+ // balasan bot di inbox. Penanda `[n]` di teks merujuk `citations[].n`; penanda yang tak punya
4
+ // sitasi dibiarkan sebagai teks biasa, jadi jawaban tetap terbaca walau sitasinya terpotong.
5
+ //
6
+ // Teks jawaban dirender sebagai potongan teks + tombol, BUKAN `v-html`: jawaban dihasilkan model
7
+ // dari isi dokumen tenant, dan HTML di dalamnya tak boleh pernah dieksekusi.
8
+ import { ref, computed } from 'vue'
9
+ import { useI18n } from 'vue-i18n'
10
+ import { BookOpen, Landmark } from '@lucide/vue'
11
+
12
+ const props = defineProps({
13
+ answer: { type: String, default: '' },
14
+ // [{ n, document_id, title, heading, page, scope: 'company'|'platform', excerpt }]
15
+ citations: { type: Array, default: () => [] },
16
+ // Daftar sumber di bawah jawaban. Dimatikan oleh pemakai yang sudah menampilkannya sendiri.
17
+ showSources: { type: Boolean, default: true },
18
+ })
19
+
20
+ const { t } = useI18n()
21
+ const active = ref(null)
22
+
23
+ const byN = computed(() => Object.fromEntries(props.citations.map((c) => [String(c.n), c])))
24
+
25
+ const parts = computed(() => {
26
+ const out = []
27
+ const re = /\[(\d+)\]/g
28
+ let last = 0
29
+ let m
30
+ const text = props.answer ?? ''
31
+ while ((m = re.exec(text))) {
32
+ if (m.index > last) out.push({ text: text.slice(last, m.index) })
33
+ out.push(byN.value[m[1]] ? { n: m[1] } : { text: m[0] })
34
+ last = m.index + m[0].length
35
+ }
36
+ if (last < text.length) out.push({ text: text.slice(last) })
37
+ return out
38
+ })
39
+
40
+ function toggle(n) {
41
+ active.value = active.value === String(n) ? null : String(n)
42
+ }
43
+ const activeCitation = computed(() => (active.value ? byN.value[active.value] : null))
44
+ const scopeLabel = (c) => (c.scope === 'platform' ? t('ai.citation.platform') : t('ai.citation.company'))
45
+ const where = (c) => [c.heading, c.page ? t('ai.citation.page', { page: c.page }) : null].filter(Boolean).join(' · ')
46
+ </script>
47
+
48
+ <template>
49
+ <div class="space-y-2" data-cited-answer>
50
+ <p class="text-sm text-gray-800 dark:text-gray-100 whitespace-pre-line break-words">
51
+ <template v-for="(p, i) in parts" :key="i">
52
+ <button
53
+ v-if="p.n"
54
+ type="button"
55
+ class="inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 mx-0.5 align-text-top rounded text-[10px] font-semibold transition-colors"
56
+ :class="active === p.n ? 'bg-blue-600 text-white' : 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300'"
57
+ :aria-label="t('ai.citation.open', { n: p.n })"
58
+ :aria-expanded="active === p.n"
59
+ :data-citation="p.n"
60
+ @click="toggle(p.n)"
61
+ >{{ p.n }}</button>
62
+ <template v-else>{{ p.text }}</template>
63
+ </template>
64
+ </p>
65
+
66
+ <div v-if="activeCitation" class="rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 p-3" data-citation-detail>
67
+ <p class="text-xs font-medium text-blue-800 dark:text-blue-300 flex items-center gap-1.5">
68
+ <Landmark v-if="activeCitation.scope === 'platform'" class="w-3.5 h-3.5 shrink-0" />
69
+ <BookOpen v-else class="w-3.5 h-3.5 shrink-0" />
70
+ [{{ activeCitation.n }}] {{ activeCitation.title }}
71
+ <span class="font-normal text-blue-600/80 dark:text-blue-400/80">· {{ scopeLabel(activeCitation) }}</span>
72
+ </p>
73
+ <p v-if="where(activeCitation)" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ where(activeCitation) }}</p>
74
+ <p v-if="activeCitation.excerpt" class="mt-1.5 text-xs text-gray-700 dark:text-gray-300 whitespace-pre-line">{{ activeCitation.excerpt }}</p>
75
+ </div>
76
+
77
+ <ul v-if="showSources && citations.length" class="space-y-1">
78
+ <li v-for="c in citations" :key="c.n">
79
+ <button type="button" class="text-left text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400" @click="toggle(c.n)">
80
+ [{{ c.n }}] {{ c.title }}<span v-if="where(c)"> — {{ where(c) }}</span>
81
+ <span v-if="c.scope === 'platform'" class="ml-1 text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">{{ t('ai.citation.platform') }}</span>
82
+ </button>
83
+ </li>
84
+ </ul>
85
+ </div>
86
+ </template>