@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
@@ -0,0 +1,138 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useAppStore } from '@initsolusidigital/worksuite-core/stores/app.js'
5
+ import { useCustomWidget } from '@initsolusidigital/worksuite-core/composables/useCustomWidget.js'
6
+ import { Loader2, TriangleAlert, Database } from '@lucide/vue'
7
+
8
+ // Renderer custom widget dashboard. Menarik data via proxy backend (useCustomWidget) lalu
9
+ // me-render sesuai `config.viz`: line/bar/pie (ApexCharts), table, atau stat. Registry widget
10
+ // milik app tak perlu mendaftarkannya: id `custom:*` diarahkan ke komponen ini oleh
11
+ // AppDashboardGrid.
12
+ const props = defineProps({
13
+ config: { type: Object, required: true }, // { title, viz, source, mapping, refresh }
14
+ })
15
+
16
+ const { t } = useI18n()
17
+ const appStore = useAppStore()
18
+ const { loading, error, series, table, statValue } = useCustomWidget(() => props.config)
19
+
20
+ const viz = computed(() => props.config?.viz || 'stat')
21
+
22
+ // Palet warna chart (selaras aksen platform: biru → ungu → amber → hijau → merah → violet)
23
+ const PALETTE = ['#3b82f6', '#6366f1', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6']
24
+
25
+ // Opsi umum ApexCharts — theme mengikuti dark mode app, kromnya diminimalkan (widget kecil).
26
+ const baseOptions = computed(() => ({
27
+ chart: { toolbar: { show: false }, animations: { enabled: false }, background: 'transparent', fontFamily: 'inherit', parentHeightOffset: 0 },
28
+ theme: { mode: appStore.darkMode ? 'dark' : 'light' },
29
+ colors: PALETTE,
30
+ dataLabels: { enabled: false },
31
+ grid: { borderColor: appStore.darkMode ? '#374151' : '#e5e7eb', strokeDashArray: 4, padding: { left: 8, right: 8, top: 0, bottom: 0 } },
32
+ tooltip: { theme: appStore.darkMode ? 'dark' : 'light' },
33
+ legend: { labels: { colors: appStore.darkMode ? '#9ca3af' : '#6b7280' } },
34
+ }))
35
+
36
+ const chartType = computed(() => ({ line: 'line', bar: 'bar', pie: 'pie' }[viz.value] || 'line'))
37
+
38
+ const chartSeries = computed(() => {
39
+ if (viz.value === 'pie') return series.value.values
40
+ return [{ name: props.config?.title || '', data: series.value.values }]
41
+ })
42
+
43
+ const chartOptions = computed(() => {
44
+ const b = baseOptions.value
45
+ if (viz.value === 'pie') {
46
+ return { ...b, chart: { ...b.chart, type: 'pie' }, labels: series.value.labels, legend: { ...b.legend, position: 'bottom' } }
47
+ }
48
+ if (viz.value === 'bar') {
49
+ return {
50
+ ...b, chart: { ...b.chart, type: 'bar' },
51
+ plotOptions: { bar: { borderRadius: 4, columnWidth: '55%' } },
52
+ xaxis: { categories: series.value.labels, labels: { style: { colors: appStore.darkMode ? '#9ca3af' : '#6b7280' } } },
53
+ yaxis: { labels: { style: { colors: appStore.darkMode ? '#9ca3af' : '#6b7280' } } },
54
+ }
55
+ }
56
+ return {
57
+ ...b, chart: { ...b.chart, type: 'line' },
58
+ stroke: { curve: 'smooth', width: 2.5 },
59
+ xaxis: { categories: series.value.labels, labels: { style: { colors: appStore.darkMode ? '#9ca3af' : '#6b7280' } } },
60
+ yaxis: { labels: { style: { colors: appStore.darkMode ? '#9ca3af' : '#6b7280' } } },
61
+ }
62
+ })
63
+
64
+ // Pesan error: kode dikenal → i18n; selain itu tampilkan pesan backend apa adanya.
65
+ const errorText = computed(() => {
66
+ const e = error.value
67
+ if (!e) return ''
68
+ return ['noUrl', 'fetchFailed'].includes(e) ? t(`dashboard.custom.err.${e}`) : e
69
+ })
70
+
71
+ // Kosong = fetch sukses tapi mapping tak menghasilkan data (kemungkinan path salah)
72
+ const isEmpty = computed(() => {
73
+ if (loading.value || error.value) return false
74
+ if (viz.value === 'stat') return statValue.value == null
75
+ if (viz.value === 'table') return table.value.rows.length === 0
76
+ return series.value.values.length === 0
77
+ })
78
+ </script>
79
+
80
+ <template>
81
+ <div class="w-full h-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex flex-col overflow-hidden">
82
+ <!-- Header: judul widget -->
83
+ <div class="px-4 pt-3 pb-2 shrink-0">
84
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate">{{ config.title }}</h3>
85
+ </div>
86
+
87
+ <!-- Body -->
88
+ <div class="flex-1 min-h-0 px-4 pb-3 relative">
89
+ <!-- Loading -->
90
+ <div v-if="loading" class="absolute inset-0 flex items-center justify-center text-gray-400">
91
+ <Loader2 class="w-5 h-5 animate-spin" />
92
+ </div>
93
+
94
+ <!-- Error -->
95
+ <div v-else-if="error" class="h-full flex flex-col items-center justify-center text-center gap-1.5 px-2">
96
+ <TriangleAlert class="w-5 h-5 text-amber-500" />
97
+ <p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-3">{{ errorText }}</p>
98
+ </div>
99
+
100
+ <!-- Empty (mapping tak menghasilkan data) -->
101
+ <div v-else-if="isEmpty" class="h-full flex flex-col items-center justify-center text-center gap-1.5">
102
+ <Database class="w-5 h-5 text-gray-300 dark:text-gray-600" />
103
+ <p class="text-xs text-gray-400 dark:text-gray-500">{{ t('dashboard.custom.noData') }}</p>
104
+ </div>
105
+
106
+ <!-- Stat -->
107
+ <div v-else-if="viz === 'stat'" class="h-full flex flex-col items-center justify-center">
108
+ <span class="text-3xl font-bold text-gray-900 dark:text-white break-all text-center">{{ statValue }}</span>
109
+ </div>
110
+
111
+ <!-- Table -->
112
+ <div v-else-if="viz === 'table'" class="h-full overflow-auto scrollbar-rounded -mx-1 px-1">
113
+ <table class="w-full text-xs">
114
+ <thead>
115
+ <tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
116
+ <th v-for="col in table.columns" :key="col" class="py-1.5 pr-3 font-medium whitespace-nowrap">{{ col }}</th>
117
+ </tr>
118
+ </thead>
119
+ <tbody>
120
+ <tr v-for="(row, i) in table.rows" :key="i" class="border-b border-gray-100 dark:border-gray-700/50 last:border-0">
121
+ <td v-for="(cell, j) in row" :key="j" class="py-1.5 pr-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">{{ cell }}</td>
122
+ </tr>
123
+ </tbody>
124
+ </table>
125
+ </div>
126
+
127
+ <!-- Chart (line/bar/pie) -->
128
+ <apexchart
129
+ v-else
130
+ width="100%"
131
+ height="100%"
132
+ :type="chartType"
133
+ :options="chartOptions"
134
+ :series="chartSeries"
135
+ />
136
+ </div>
137
+ </div>
138
+ </template>
@@ -0,0 +1,173 @@
1
+ <script setup>
2
+ import { ref, computed, watch } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { dashboardApi } from '@initsolusidigital/worksuite-core/api/endpoints/dashboard.js'
5
+ import { useFormValidation } from '@initsolusidigital/worksuite-core/composables/useFormValidation.js'
6
+ import AppModal from '@initsolusidigital/worksuite-ui/AppModal.vue'
7
+ import AppInput from '@initsolusidigital/worksuite-ui/AppInput.vue'
8
+ import AppSelect from '@initsolusidigital/worksuite-ui/AppSelect.vue'
9
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
10
+ import { Play, Loader2 } from '@lucide/vue'
11
+
12
+ // Form tambah/edit custom widget dashboard. Menyusun `config`
13
+ // (judul, viz, source URL/method, mapping field, refresh) lalu emit ke pemanggil yang
14
+ // menyimpannya ke layout (useDashboardLayout.saveCustomWidget). Tombol "Uji" memanggil
15
+ // proxy backend agar user melihat struktur JSON → tahu path mapping yang benar.
16
+ const props = defineProps({
17
+ value: { type: Object, default: null }, // config yang diedit (null = tambah baru)
18
+ })
19
+ const emit = defineEmits(['close', 'save'])
20
+
21
+ const { t } = useI18n()
22
+ const { fieldErrors, requireFilled, clearErrors } = useFormValidation()
23
+
24
+ const VIZ_OPTIONS = computed(() => [
25
+ { value: 'stat', label: t('dashboard.custom.viz.stat') },
26
+ { value: 'table', label: t('dashboard.custom.viz.table') },
27
+ { value: 'line', label: t('dashboard.custom.viz.line') },
28
+ { value: 'bar', label: t('dashboard.custom.viz.bar') },
29
+ { value: 'pie', label: t('dashboard.custom.viz.pie') },
30
+ ])
31
+ const METHOD_OPTIONS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
32
+ const REFRESH_OPTIONS = computed(() => [
33
+ { value: '0', label: t('dashboard.custom.refresh.off') },
34
+ { value: '60', label: t('dashboard.custom.refresh.m1') },
35
+ { value: '300', label: t('dashboard.custom.refresh.m5') },
36
+ { value: '900', label: t('dashboard.custom.refresh.m15') },
37
+ { value: '3600', label: t('dashboard.custom.refresh.h1') },
38
+ ])
39
+
40
+ const form = ref(blank())
41
+ function blank() {
42
+ return { title: '', viz: 'stat', url: '', method: 'GET', refresh: '0', rows: '', x: '', y: '', value: '' }
43
+ }
44
+ function initFrom(c) {
45
+ form.value = {
46
+ title: c?.title || '',
47
+ viz: c?.viz || 'stat',
48
+ url: c?.source?.url || '',
49
+ method: c?.source?.method || 'GET',
50
+ refresh: String(c?.refresh ?? 0),
51
+ rows: c?.mapping?.rows || '',
52
+ x: c?.mapping?.x || '',
53
+ y: c?.mapping?.y || '',
54
+ value: c?.mapping?.value || '',
55
+ }
56
+ }
57
+ const isEdit = computed(() => !!props.value)
58
+ const isChart = computed(() => ['line', 'bar', 'pie'].includes(form.value.viz))
59
+
60
+ // ── Uji koneksi ──────────────────────────────────────────────────────────────
61
+ // Dideklarasikan SEBELUM watch(immediate) di bawah — watch memanggil resetTest() sinkron
62
+ // saat setup, jadi ref-nya wajib sudah ada (hindari temporal dead zone).
63
+ const testLoading = ref(false)
64
+ const testError = ref('')
65
+ const testResult = ref('') // JSON pretty (dipangkas) untuk membantu user menyusun mapping
66
+ function resetTest() { testLoading.value = false; testError.value = ''; testResult.value = '' }
67
+
68
+ // Re-init tiap kali dibuka utk config berbeda
69
+ watch(() => props.value, (v) => { initFrom(v); clearErrors(); resetTest() }, { immediate: true })
70
+
71
+ async function runTest() {
72
+ if (!form.value.url) { testError.value = t('dashboard.custom.err.noUrl'); return }
73
+ testLoading.value = true; testError.value = ''; testResult.value = ''
74
+ try {
75
+ const { data } = await dashboardApi.widgetProxy({ url: form.value.url, method: form.value.method })
76
+ const body = data?.data?.body ?? ''
77
+ let pretty = body
78
+ try { pretty = JSON.stringify(JSON.parse(body), null, 2) } catch { /* biarkan teks mentah */ }
79
+ testResult.value = pretty.length > 4000 ? pretty.slice(0, 4000) + '\n…' : pretty
80
+ } catch (e) {
81
+ testError.value = e?.response?.data?.message || t('dashboard.custom.err.fetchFailed')
82
+ } finally {
83
+ testLoading.value = false
84
+ }
85
+ }
86
+
87
+ function buildConfig() {
88
+ const f = form.value
89
+ const mapping = {}
90
+ if (f.viz === 'stat') mapping.value = f.value
91
+ else if (f.viz === 'table') { if (f.rows) mapping.rows = f.rows }
92
+ else { if (f.rows) mapping.rows = f.rows; if (f.x) mapping.x = f.x; if (f.y) mapping.y = f.y }
93
+ return {
94
+ title: f.title.trim(),
95
+ viz: f.viz,
96
+ source: { url: f.url.trim(), method: f.method },
97
+ mapping,
98
+ refresh: Number(f.refresh) || 0,
99
+ }
100
+ }
101
+
102
+ // ⚠️ SENGAJA memakai `useFormValidation`, BUKAN `useFormSubmit`. Alasannya: form ini tak pernah
103
+ // memanggil API — ia
104
+ // memvalidasi lalu `emit('save', config)`, dan konfigurasinya menumpang JSON `dashboard_layout`
105
+ // yang disimpan di tempat lain. `useFormSubmit` ada untuk membungkus REQUEST (status→pesan,
106
+ // toast, saving); di sini tak ada request untuk dibungkus, jadi memakainya cuma menambah
107
+ // lapisan yang tak menjawab apa pun. Jangan "diseragamkan" tanpa membaca ini dulu.
108
+ function submit() {
109
+ clearErrors()
110
+ // stat wajib punya path nilai; chart/table cukup url (mapping opsional, auto-deteksi bentuk)
111
+ if (!requireFilled(form.value, { title: true, url: true, value: form.value.viz === 'stat' })) return
112
+ emit('save', buildConfig())
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <AppModal
118
+ :title="isEdit ? t('dashboard.custom.editTitle') : t('dashboard.custom.addTitle')"
119
+ :subtitle="t('dashboard.custom.subtitle')"
120
+ size="lg"
121
+ @close="emit('close')"
122
+ >
123
+ <div class="space-y-4">
124
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
125
+ <AppInput v-model="form.title" :label="t('dashboard.custom.field.title')" required :error="fieldErrors.title?.[0]" :placeholder="t('dashboard.custom.ph.title')" />
126
+ <AppSelect v-model="form.viz" :label="t('dashboard.custom.field.viz')" :options="VIZ_OPTIONS" :placeholder="''" />
127
+ </div>
128
+
129
+ <!-- Sumber data -->
130
+ <AppInput v-model="form.url" :label="t('dashboard.custom.field.url')" required :error="fieldErrors.url?.[0]" placeholder="https://api.frankfurter.dev/v1/latest" :hint="t('dashboard.custom.hint.publicOnly')" />
131
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
132
+ <AppSelect v-model="form.method" :label="t('dashboard.custom.field.method')" :options="METHOD_OPTIONS" :placeholder="''" />
133
+ <AppSelect v-model="form.refresh" :label="t('dashboard.custom.field.refresh')" :options="REFRESH_OPTIONS" :placeholder="''" />
134
+ </div>
135
+
136
+ <!-- Uji koneksi (bantu susun mapping) -->
137
+ <div>
138
+ <AppButton variant="secondary" size="sm" :disabled="testLoading" @click="runTest">
139
+ <Loader2 v-if="testLoading" class="w-3.5 h-3.5 animate-spin" />
140
+ <Play v-else class="w-3.5 h-3.5" />
141
+ {{ t('dashboard.custom.test') }}
142
+ </AppButton>
143
+ <p v-if="testError" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ testError }}</p>
144
+ <pre v-else-if="testResult" class="mt-2 max-h-48 overflow-auto scrollbar-rounded text-[11px] leading-relaxed bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-gray-700 dark:text-gray-300">{{ testResult }}</pre>
145
+ </div>
146
+
147
+ <!-- Mapping field — tergantung viz -->
148
+ <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
149
+ <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">{{ t('dashboard.custom.mappingTitle') }}</p>
150
+
151
+ <!-- stat -->
152
+ <AppInput v-if="form.viz === 'stat'" v-model="form.value" :label="t('dashboard.custom.field.valuePath')" required :error="fieldErrors.value?.[0]" placeholder="rates.USD" :hint="t('dashboard.custom.hint.path')" />
153
+
154
+ <!-- table -->
155
+ <AppInput v-else-if="form.viz === 'table'" v-model="form.rows" :label="t('dashboard.custom.field.rowsPath')" placeholder="rates" :hint="t('dashboard.custom.hint.rows')" />
156
+
157
+ <!-- line/bar/pie -->
158
+ <div v-else class="space-y-4">
159
+ <AppInput v-model="form.rows" :label="t('dashboard.custom.field.rowsPath')" placeholder="rates" :hint="t('dashboard.custom.hint.rowsChart')" />
160
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
161
+ <AppInput v-model="form.x" :label="t('dashboard.custom.field.xField')" placeholder="date" :hint="t('dashboard.custom.hint.field')" />
162
+ <AppInput v-model="form.y" :label="t('dashboard.custom.field.yField')" placeholder="rate" :hint="t('dashboard.custom.hint.field')" />
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <template #footer>
169
+ <AppButton variant="ghost" @click="emit('close')">{{ t('common.cancel') }}</AppButton>
170
+ <AppButton variant="primary" @click="submit">{{ isEdit ? t('common.save') : t('common.add') }}</AppButton>
171
+ </template>
172
+ </AppModal>
173
+ </template>
@@ -0,0 +1,200 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, ref } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { GridLayout, GridItem } from 'grid-layout-plus'
5
+ import { X, GripVertical, Pencil } from '@lucide/vue'
6
+ import AppCustomWidget from './AppCustomWidget.vue'
7
+ import AppCustomWidgetForm from './AppCustomWidgetForm.vue'
8
+
9
+ // Grid dashboard yang bisa di-atur. Drag + resize widget.
10
+ // State dari useDashboardLayout (prop `dash`); katalog widget dari prop `widgets` (milik app).
11
+ // Toolbar (Atur/Tambah/Reset/Selesai) DIPISAH ke AppDashboardToolbar (ditaruh sebaris
12
+ // breadcrumb via slot AppLayout `header-actions`) — grid ini murni papan widget.
13
+ // Widget custom (id `custom:*`) dirender oleh AppCustomWidget dgn `config` dari item layout.
14
+ const { t } = useI18n()
15
+
16
+ const props = defineProps({
17
+ dash: { type: Object, required: true }, // return useDashboardLayout
18
+ widgets: { type: Object, required: true }, // { [id]: { component, titleKey, ... } }
19
+ })
20
+
21
+ // WAJIB destructure dari props.dash: ref yang diakses sbg `props.dash.x` di template TIDAK
22
+ // auto-unwrap (dash = objek plain, bukan reactive).
23
+ const {
24
+ layout, editMode, removeWidget, isCustomWidget,
25
+ customFormOpen, customFormValue, openCustomForm, saveCustomWidget, closeCustomForm,
26
+ } = props.dash
27
+
28
+ // Judul untuk handle drag & tooltip: custom → dari config; built-in → dari i18n registry.
29
+ const widgetTitle = (item) => {
30
+ if (isCustomWidget(item.i)) return item.config?.title || t('dashboard.custom.untitled')
31
+ const key = props.widgets[item.i]?.titleKey
32
+ return key ? t(key) : item.i
33
+ }
34
+
35
+ // ── Mode tumpuk mobile (<640px) ───────────────────────────────────────────────
36
+ // Grid 12 kolom dipertahankan di semua lebar oleh grid-layout-plus (:responsive
37
+ // sengaja false — layout tersimpan per-user memakai koordinat 12 kolom, mode
38
+ // responsive pustaka akan menulis-balik koordinat itu dan merusak simpanan).
39
+ // Di bawah `sm`, sel 4-kolom cuma ±110px — chart/tabel tak terbaca. Jadi di
40
+ // mobile grid DIGANTI tumpukan satu kolom urut posisi visual (y, lalu x);
41
+ // drag/resize tak tersedia (tak bermakna di satu kolom), hapus/edit tetap ada.
42
+ const mobileQuery = window.matchMedia('(max-width: 639px)')
43
+ const isMobile = ref(mobileQuery.matches)
44
+ const onMobileChange = (e) => { isMobile.value = e.matches }
45
+ mobileQuery.addEventListener('change', onMobileChange)
46
+ onBeforeUnmount(() => mobileQuery.removeEventListener('change', onMobileChange))
47
+
48
+ const stackedLayout = computed(() =>
49
+ [...layout.value].sort((a, b) => (a.y - b.y) || (a.x - b.x)),
50
+ )
51
+
52
+ // Tinggi kartu di mode tumpuk = tinggi sel grid yang sama (h × rowHeight + gap),
53
+ // supaya chart ber-tinggi-tetap (Apex) tetap punya ruang yang sama dgn di grid.
54
+ const stackedHeight = (item) => `${item.h * 72 + (item.h - 1) * 16}px`
55
+ </script>
56
+
57
+ <template>
58
+ <div>
59
+ <!-- ── Grid ─────────────────────────────────────────────────────────────── -->
60
+ <!-- key=editMode: paksa re-mount saat ganti mode supaya drag/resize handle sinkron.
61
+ -mx-4/-mt-4 = cancel inset tepi bawaan grid (item selalu +marginX/-Y dari tepi
62
+ container = 16px). Tanpa ini widget 16px lebih masuk daripada breadcrumb/halaman
63
+ lain → tak sejajar. Nilai HARUS = marginX/Y grid (16px = -4). -->
64
+ <!-- Mode tumpuk mobile: satu kolom, urut posisi visual. Hapus/edit widget
65
+ tetap bisa (mode Atur), hanya drag/resize yang tak tersedia. -->
66
+ <div v-if="isMobile" class="space-y-4">
67
+ <div
68
+ v-for="item in stackedLayout"
69
+ :key="item.i"
70
+ class="relative"
71
+ :class="editMode ? 'ring-2 ring-blue-300 dark:ring-blue-700 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 rounded-xl' : ''"
72
+ :style="{ height: stackedHeight(item) }"
73
+ >
74
+ <div v-if="editMode" class="absolute top-1.5 right-1.5 z-20 flex items-center gap-1">
75
+ <button
76
+ v-if="isCustomWidget(item.i)"
77
+ type="button"
78
+ class="w-7 h-7 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 text-gray-500 dark:text-gray-400 shadow hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
79
+ :title="t('dashboard.custom.editTitle')"
80
+ :aria-label="t('dashboard.custom.editTitle')"
81
+ @click="openCustomForm(item)"
82
+ >
83
+ <Pencil class="w-3.5 h-3.5" />
84
+ </button>
85
+ <button
86
+ type="button"
87
+ class="w-7 h-7 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 text-red-500 shadow hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
88
+ :title="t('dashboard.grid.removeWidget')"
89
+ :aria-label="t('dashboard.grid.removeWidget')"
90
+ @click="removeWidget(item.i)"
91
+ >
92
+ <X class="w-4 h-4" />
93
+ </button>
94
+ </div>
95
+ <div class="h-full overflow-hidden" :class="editMode ? 'pointer-events-none select-none' : ''">
96
+ <AppCustomWidget v-if="isCustomWidget(item.i)" :config="item.config" />
97
+ <component v-else :is="widgets[item.i]?.component" :widget-id="item.i" />
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <GridLayout
103
+ v-else
104
+ :key="editMode ? 'edit' : 'view'"
105
+ class="-mx-4 -mt-4"
106
+ :layout="layout"
107
+ :col-num="12"
108
+ :row-height="72"
109
+ :margin="[16, 16]"
110
+ :is-draggable="editMode"
111
+ :is-resizable="editMode"
112
+ :responsive="false"
113
+ :vertical-compact="true"
114
+ :use-css-transforms="true"
115
+ drag-allow-from=".widget-drag-handle"
116
+ >
117
+ <GridItem
118
+ v-for="item in layout"
119
+ :key="item.i"
120
+ :i="item.i"
121
+ :x="item.x"
122
+ :y="item.y"
123
+ :w="item.w"
124
+ :h="item.h"
125
+ :min-w="item.minW"
126
+ :min-h="item.minH"
127
+ >
128
+ <div
129
+ class="relative h-full"
130
+ :class="editMode ? 'ring-2 ring-blue-300 dark:ring-blue-700 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 rounded-xl' : ''"
131
+ >
132
+ <!-- Overlay kontrol saat mode edit -->
133
+ <template v-if="editMode">
134
+ <div class="widget-drag-handle absolute top-1.5 left-1.5 z-20 w-7 h-7 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 text-gray-500 dark:text-gray-400 shadow cursor-move" :title="widgetTitle(item)">
135
+ <GripVertical class="w-4 h-4" />
136
+ </div>
137
+ <div class="absolute top-1.5 right-1.5 z-20 flex items-center gap-1">
138
+ <!-- Edit config: hanya widget custom -->
139
+ <button
140
+ v-if="isCustomWidget(item.i)"
141
+ type="button"
142
+ class="w-7 h-7 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 text-gray-500 dark:text-gray-400 shadow hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
143
+ :title="t('dashboard.custom.editTitle')"
144
+ @click="openCustomForm(item)"
145
+ >
146
+ <Pencil class="w-3.5 h-3.5" />
147
+ </button>
148
+ <button
149
+ type="button"
150
+ class="w-7 h-7 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 text-red-500 shadow hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
151
+ :title="t('dashboard.grid.removeWidget')"
152
+ @click="removeWidget(item.i)"
153
+ >
154
+ <X class="w-4 h-4" />
155
+ </button>
156
+ </div>
157
+ </template>
158
+
159
+ <!-- Konten widget — overflow-hidden supaya rapi di dalam sel -->
160
+ <div class="h-full overflow-hidden" :class="editMode ? 'pointer-events-none select-none' : ''">
161
+ <AppCustomWidget v-if="isCustomWidget(item.i)" :config="item.config" />
162
+ <component v-else :is="widgets[item.i]?.component" :widget-id="item.i" />
163
+ </div>
164
+ </div>
165
+ </GridItem>
166
+ </GridLayout>
167
+
168
+ <!-- Form tambah/edit custom widget — dibuka dari toolbar (Tambah Kustom) atau
169
+ tombol edit per-widget. State di `dash` supaya kedua pemicu berbagi satu sumber. -->
170
+ <AppCustomWidgetForm
171
+ v-if="customFormOpen"
172
+ :value="customFormValue"
173
+ @save="saveCustomWidget"
174
+ @close="closeCustomForm"
175
+ />
176
+ </div>
177
+ </template>
178
+
179
+ <style>
180
+ /* grid-layout-plus tidak ship file CSS (v1.1.x) — sediakan CSS inti sendiri.
181
+ Posisi item pakai transform inline (use-css-transforms), CSS ini utk transisi,
182
+ placeholder, dan handle resize. */
183
+ .vgl-layout { position: relative; transition: height 0.2s ease; }
184
+ .vgl-item { box-sizing: border-box; touch-action: none; transition: all 0.18s ease; transition-property: left, top, right; }
185
+ .vgl-item.vgl-item--transform { left: 0; right: auto; transition-property: transform; }
186
+ .vgl-item.vgl-item--dragging { transition: none; z-index: 3; user-select: none; }
187
+ .vgl-item.vgl-item--resizing { opacity: 0.85; z-index: 3; }
188
+ /* Placeholder = bayangan tujuan saat drag: garis putus-putus biru + isi tipis, jelas terlihat */
189
+ .vgl-item.vgl-item--placeholder { background: rgb(59 130 246 / 0.08); border: 2px dashed rgb(59 130 246 / 0.65); border-radius: 0.75rem; z-index: 2; user-select: none; transition-duration: 0.08s; }
190
+ :root[data-theme="dark"] .vgl-item.vgl-item--placeholder,
191
+ .dark .vgl-item.vgl-item--placeholder { background: rgb(96 165 250 / 0.1); border-color: rgb(96 165 250 / 0.6); }
192
+ .vgl-item__resizer {
193
+ position: absolute; right: 0; bottom: 0; width: 20px; height: 20px; z-index: 20;
194
+ cursor: se-resize;
195
+ }
196
+ .vgl-item__resizer::after {
197
+ content: ''; position: absolute; right: 4px; bottom: 4px; width: 6px; height: 6px;
198
+ border-right: 2px solid rgb(107 114 128 / 0.7); border-bottom: 2px solid rgb(107 114 128 / 0.7);
199
+ }
200
+ </style>
@@ -0,0 +1,66 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { Pencil, Check, Plus, RotateCcw, Sparkles } from '@lucide/vue'
5
+ import AppButton from '@initsolusidigital/worksuite-ui/AppButton.vue'
6
+
7
+ // Toolbar dashboard — dipisah dari grid supaya bisa ditaruh SEBARIS dengan breadcrumb
8
+ // (slot AppLayout `header-actions`). State dari useDashboardLayout (prop `dash`).
9
+ const { t } = useI18n()
10
+
11
+ const props = defineProps({
12
+ dash: { type: Object, required: true },
13
+ widgets: { type: Object, required: true },
14
+ })
15
+
16
+ // Ref di objek plain (props.dash) tak auto-unwrap di template → destructure dulu.
17
+ const { editMode, saving, availableToAdd, toggleEdit, reset, addWidget, openCustomForm } = props.dash
18
+ const showAddMenu = ref(false)
19
+
20
+ const widgetTitle = (id) => {
21
+ const key = props.widgets[id]?.titleKey
22
+ return key ? t(key) : id
23
+ }
24
+ function addAndClose(id) {
25
+ addWidget(id)
26
+ showAddMenu.value = false
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <div class="flex flex-wrap items-center justify-end gap-2">
32
+ <template v-if="editMode">
33
+ <!-- Tambah widget -->
34
+ <div class="relative">
35
+ <AppButton variant="secondary" size="sm" @click="showAddMenu = !showAddMenu" :disabled="!availableToAdd.length">
36
+ <Plus class="w-3.5 h-3.5" />{{ t('dashboard.grid.addWidget') }}
37
+ </AppButton>
38
+ <div
39
+ v-if="showAddMenu && availableToAdd.length"
40
+ class="absolute right-0 mt-1 w-56 max-h-64 overflow-y-auto scrollbar-rounded bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-100 dark:border-gray-700 z-30 py-1"
41
+ >
42
+ <button
43
+ v-for="w in availableToAdd"
44
+ :key="w.id"
45
+ type="button"
46
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
47
+ @click="addAndClose(w.id)"
48
+ >{{ widgetTitle(w.id) }}</button>
49
+ </div>
50
+ </div>
51
+ <!-- Widget kustom (dari API eksternal) -->
52
+ <AppButton variant="secondary" size="sm" @click="openCustomForm(null)">
53
+ <Sparkles class="w-3.5 h-3.5" />{{ t('dashboard.custom.add') }}
54
+ </AppButton>
55
+ <AppButton variant="ghost" size="sm" @click="reset()">
56
+ <RotateCcw class="w-3.5 h-3.5" />{{ t('dashboard.grid.reset') }}
57
+ </AppButton>
58
+ <AppButton variant="primary" size="sm" :loading="saving" @click="toggleEdit()">
59
+ <Check class="w-3.5 h-3.5" />{{ t('dashboard.grid.done') }}
60
+ </AppButton>
61
+ </template>
62
+ <AppButton v-else variant="secondary" size="sm" @click="toggleEdit()">
63
+ <Pencil class="w-3.5 h-3.5" />{{ t('dashboard.grid.arrange') }}
64
+ </AppButton>
65
+ </div>
66
+ </template>