@initsolusidigital/worksuite-shell 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +122 -2
- package/package.json +44 -4
- package/src/components/ai/AppAssistantDraft.vue +120 -0
- package/src/components/ai/AppAssistantWidget.vue +245 -0
- package/src/components/ai/AppCitedAnswer.vue +86 -0
- package/src/components/approvals/AppApprovalInbox.vue +265 -0
- package/src/components/approvals/AppApprovalSubjectModal.vue +197 -0
- package/src/components/chat/AppChatBubble.vue +324 -0
- package/src/components/chat/AppChatList.vue +249 -0
- package/src/components/chat/AppChatPanel.vue +315 -0
- package/src/components/chat/AppChatWidget.vue +126 -0
- package/src/components/communications/AppChannelBadge.vue +47 -0
- package/src/components/communications/AppConversationThread.vue +439 -0
- package/src/components/customFields/CustomFieldFilterPanel.vue +50 -0
- package/src/components/customFields/CustomFieldInput.vue +422 -0
- package/src/components/customFields/CustomFieldValue.vue +129 -0
- package/src/components/customFields/CustomFieldsSection.vue +65 -0
- package/src/components/customFields/fieldTypes.js +23 -0
- package/src/components/dashboard/AppCustomWidget.vue +138 -0
- package/src/components/dashboard/AppCustomWidgetForm.vue +173 -0
- package/src/components/dashboard/AppDashboardGrid.vue +200 -0
- package/src/components/dashboard/AppDashboardToolbar.vue +66 -0
- package/src/components/dashboard/AppSubAppsCatalog.vue +89 -0
- package/src/components/dataTools/AppImportExport.vue +724 -0
- package/src/components/dataTools/AppImportSchemaGuide.vue +130 -0
- package/src/components/hrMetrics/AppHrMetricsDashboard.vue +231 -0
- package/src/components/hrMetrics/AppHrMetricsEmployees.vue +87 -0
- package/src/components/hrMetrics/AppHrMetricsStat.vue +61 -0
- package/src/components/kpi/AppKpiAppraisalConfig.vue +284 -0
- package/src/components/kpi/AppKpiAppraisals.vue +733 -0
- package/src/components/kpi/AppKpiDefinitionModal.vue +317 -0
- package/src/components/kpi/AppKpiEntryModal.vue +122 -0
- package/src/components/kpi/AppKpiLeaderboard.vue +108 -0
- package/src/components/kpi/AppKpiMyCard.vue +134 -0
- package/src/components/kpi/AppKpiPeriodPicker.vue +115 -0
- package/src/components/kpi/AppKpiReportTable.vue +214 -0
- package/src/components/kpi/AppKpiScorecard.vue +242 -0
- package/src/components/kpi/AppKpiTargetsModal.vue +299 -0
- package/src/components/kpi/AppKpiTeam.vue +221 -0
- package/src/components/kpi/AppKpiWeightProfiles.vue +244 -0
- package/src/components/kpi/AppKpiWorkspace.vue +479 -0
- package/src/components/layout/AppGlobalSearch.vue +149 -0
- package/src/components/layout/AppLanguagePopup.vue +94 -0
- package/src/components/layout/AppLauncherPopup.vue +147 -0
- package/src/components/layout/AppLegalConsentModal.vue +484 -0
- package/src/components/layout/AppNavbar.vue +402 -0
- package/src/components/layout/AppNotificationContent.vue +173 -0
- package/src/components/layout/AppNotificationDrawer.vue +98 -0
- package/src/components/layout/AppNotificationPopup.vue +67 -0
- package/src/components/layout/AppPlatformBanners.vue +192 -0
- package/src/components/layout/AppSidebar.vue +226 -0
- package/src/components/layout/AppSidebarNav.vue +119 -0
- package/src/components/layout/AppUserMenu.vue +89 -0
- package/src/components/layout/AppUserMenuContent.vue +59 -0
- package/src/composables/useKpi.js +232 -0
- package/src/composables/useLgViewport.js +25 -0
- package/src/composables/useNavigation.js +177 -0
- package/src/layouts/AppLayout.vue +207 -0
- package/src/locales/en.json +1362 -0
- package/src/locales/id.json +1396 -0
- package/src/pages/auth/AccountBlocked.vue +55 -0
- package/src/pages/auth/IdentityRedirect.vue +37 -0
- package/src/pages/profile/Layout.vue +210 -0
- package/src/pages/profile/partials/AccountSection.vue +139 -0
- package/src/pages/profile/partials/ApprovalDelegationSection.vue +389 -0
- package/src/pages/profile/partials/DevicesCard.vue +222 -0
- package/src/pages/profile/partials/PreferencesSection.vue +87 -0
- package/src/pages/profile/partials/SecuritySection.vue +93 -0
- package/src/pages/profile/partials/SessionsCard.vue +418 -0
- package/src/pages/profile/partials/SessionsSection.vue +58 -0
- package/src/pages/profile/partials/SupportAccessSection.vue +173 -0
- package/src/pages/profile/partials/TrustedDevicesCard.vue +141 -0
- package/src/pages/profile/partials/TwoFactorSection.vue +304 -0
- package/src/shellSetup.js +46 -0
|
@@ -0,0 +1,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>
|