@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
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
|
-
#
|
|
1
|
+
# @initsolusidigital/worksuite-shell
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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>
|