From ba6485a3e7812faa8b22fbea916fa47ddc6c978e Mon Sep 17 00:00:00 2001 From: Khafid Prayoga Date: Tue, 9 Sep 2025 13:37:52 +0700 Subject: [PATCH 01/14] feat(division): wip tree select component feat(division): update division list components and add mock api - Replace patient API endpoint with division mock endpoint - Simplify table columns and headers for division list - Add mock API endpoint for division list with tree/flat format feat(select-tree): add collapsible tree select component with lazy loading Implement a tree select component with collapsible sections and lazy loading of child items. Includes: - Collapsible component wrappers for Vue - Command component wrappers for combobox functionality - Tree select item component with loading states - Example implementation in dev page todo: - scroll on overflow - long text truncate possibly with tooltip - more than > 5 depth of child - mutate the children lazy - integration backend for search based text and return keys feat(select-tree): add command-item component for tree selection adjust hover bg-accent (remove state on-highlighted at styling) to avoid conflict on global component refactor(select-tree): extract TreeItem interface to shared type file Move TreeItem interface to a dedicated type file for better code organization and reusability. Update components to import the interface and add styling improvements to the tree-select component. adjust text size for tree to sm refactor(select-tree): rename tree-select-item to leaf and improve component - Rename component to better reflect its purpose as a leaf node - Improve UI with better spacing and hover states - Simplify toggle logic using v-model - Add checkmark icon for selected items checkpoint wip --- app/components/app/divison/entry-form.vue | 28 ++- app/components/app/divison/list-cfg.ts | 47 +---- app/components/app/divison/tree.vue | 165 ++++++++++++++++++ app/components/content/division/entry.vue | 154 ++++++++++++++++ app/components/content/division/list.vue | 15 +- .../pub/base/select-tree/command-item.vue | 27 +++ app/components/pub/base/select-tree/leaf.vue | 40 +++++ .../pub/base/select-tree/tree-node.vue | 121 +++++++++++++ .../pub/base/select-tree/tree-select.vue | 68 ++++++++ .../pub/base/select-tree/tree-view.vue | 45 +++++ app/components/pub/base/select-tree/type.ts | 6 + app/pages/_dev/user/list.vue | 74 +++++++- server/api/v1/_dev/division/list.get.ts | 163 +++++++++++++++++ 13 files changed, 902 insertions(+), 51 deletions(-) create mode 100644 app/components/app/divison/tree.vue create mode 100644 app/components/content/division/entry.vue create mode 100644 app/components/pub/base/select-tree/command-item.vue create mode 100644 app/components/pub/base/select-tree/leaf.vue create mode 100644 app/components/pub/base/select-tree/tree-node.vue create mode 100644 app/components/pub/base/select-tree/tree-select.vue create mode 100644 app/components/pub/base/select-tree/tree-view.vue create mode 100644 app/components/pub/base/select-tree/type.ts create mode 100644 server/api/v1/_dev/division/list.get.ts diff --git a/app/components/app/divison/entry-form.vue b/app/components/app/divison/entry-form.vue index 3008d3c8..1780895a 100644 --- a/app/components/app/divison/entry-form.vue +++ b/app/components/app/divison/entry-form.vue @@ -1,6 +1,8 @@ + + diff --git a/app/components/content/division/entry.vue b/app/components/content/division/entry.vue new file mode 100644 index 00000000..a6ccf816 --- /dev/null +++ b/app/components/content/division/entry.vue @@ -0,0 +1,154 @@ + + + diff --git a/app/components/content/division/list.vue b/app/components/content/division/list.vue index f4bf78e6..511a5a1a 100644 --- a/app/components/content/division/list.vue +++ b/app/components/content/division/list.vue @@ -1,12 +1,11 @@ + + diff --git a/app/components/pub/base/select-tree/leaf.vue b/app/components/pub/base/select-tree/leaf.vue new file mode 100644 index 00000000..1dc8c892 --- /dev/null +++ b/app/components/pub/base/select-tree/leaf.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/app/components/pub/base/select-tree/tree-node.vue b/app/components/pub/base/select-tree/tree-node.vue new file mode 100644 index 00000000..da7a0bdf --- /dev/null +++ b/app/components/pub/base/select-tree/tree-node.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/app/components/pub/base/select-tree/tree-select.vue b/app/components/pub/base/select-tree/tree-select.vue new file mode 100644 index 00000000..3a7cafd8 --- /dev/null +++ b/app/components/pub/base/select-tree/tree-select.vue @@ -0,0 +1,68 @@ + + + diff --git a/app/components/pub/base/select-tree/tree-view.vue b/app/components/pub/base/select-tree/tree-view.vue new file mode 100644 index 00000000..abe6c0de --- /dev/null +++ b/app/components/pub/base/select-tree/tree-view.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/components/pub/base/select-tree/type.ts b/app/components/pub/base/select-tree/type.ts new file mode 100644 index 00000000..37d16761 --- /dev/null +++ b/app/components/pub/base/select-tree/type.ts @@ -0,0 +1,6 @@ +export interface TreeItem { + value: string + label: string + hasChildren: boolean + children?: TreeItem[] +} diff --git a/app/pages/_dev/user/list.vue b/app/pages/_dev/user/list.vue index 4c5edc33..ae207f41 100644 --- a/app/pages/_dev/user/list.vue +++ b/app/pages/_dev/user/list.vue @@ -1,9 +1,81 @@ diff --git a/server/api/v1/_dev/division/list.get.ts b/server/api/v1/_dev/division/list.get.ts new file mode 100644 index 00000000..377e1592 --- /dev/null +++ b/server/api/v1/_dev/division/list.get.ts @@ -0,0 +1,163 @@ +export default defineEventHandler(async (event) => { + // Ambil query parameters + const payload = { ...getQuery(event) } + const isTreeFormat = payload.tree === 'true' || payload.tree === '1' + + // Mock data division dengan struktur nested meta parent + const baseDivisions = [ + { + id: 1, + name: 'Direktorat Medis', + code: 'DIR-MED', + parentId: null, + }, + { + id: 2, + name: 'Bidang Medik', + code: 'BDG-MED', + parentId: 1, + }, + { + id: 3, + name: 'Tim Kerja Ranap, ICU, Bedah', + code: 'TIM-RAN', + parentId: 2, + }, + { + id: 4, + name: 'Direktorat Keperawatan', + code: 'DIR-KEP', + parentId: null, + }, + { + id: 5, + name: 'Bidang Keperawatan', + code: 'BDG-KEP', + parentId: 4, + }, + { + id: 6, + name: 'Tim Kerja Keperawatan Ranap, ICU, Bedah', + code: 'TIM-KEP', + parentId: 5, + }, + { + id: 7, + name: 'Direktorat Penunjang', + code: 'DIR-PNJ', + parentId: null, + }, + { + id: 8, + name: 'Bidang Penunjang Medik', + code: 'BDG-PNJ', + parentId: 7, + }, + { + id: 9, + name: 'Tim Kerja Radiologi', + code: 'TIM-RAD', + parentId: 8, + }, + { + id: 10, + name: 'Direktorat Produksi', + code: 'DIR-PRD', + parentId: null, + }, + { + id: 11, + name: 'Bidang Teknologi', + code: 'BDG-TEK', + parentId: 10, + }, + { + id: 12, + name: 'Tim Kerja Software Engineering', + code: 'TIM-SWE', + parentId: 11, + }, + { + id: 13, + name: 'Direktorat Operasional', + code: 'DIR-OPS', + parentId: null, + }, + { + id: 14, + name: 'Bidang HR & GA', + code: 'BDG-HRG', + parentId: 13, + }, + { + id: 15, + name: 'Tim Kerja Rekrutmen', + code: 'TIM-REC', + parentId: 14, + }, + ] + + // Menambahkan meta parent pada setiap division + const divisions = baseDivisions + .map((division) => { + const parent = baseDivisions.find((d) => d.id === division.parentId) + + const mapped = { + ...division, + meta: { + parentId: parent?.id || null, + name: parent?.name || null, + code: parent?.code || null, + }, + } + + if (mapped.meta.parentId === null) { + mapped.meta = null + } + + return mapped + }) + .sort((a, b) => { + if (a.parentId === null && b.parentId !== null) return -1 + if (a.parentId !== null && b.parentId === null) return 1 + + return a.id - b.id + }) + + // Jika tree format diminta, konversi ke struktur hierarki + if (isTreeFormat) { + const buildTree = (parentId = null) => { + return baseDivisions + .filter(division => division.parentId === parentId) + .map(division => ({ + id: division.id, + name: division.name, + code: division.code, + children: buildTree(division.id), + })) + .sort((a, b) => a.id - b.id) + } + + const treeData = buildTree() + + return { + success: true, + data: treeData, + message: 'Data division dalam format tree berhasil diambil', + meta: { + record_totalCount: baseDivisions.length, + format: 'tree', + }, + } + } + + return { + success: true, + data: divisions, + message: 'Data division berhasil diambil', + meta: { + record_totalCount: divisions.length, + format: 'flat', + }, + } +}) From e02360aea5fbc7c84e9f82355bcc6bbb55f4a2ff Mon Sep 17 00:00:00 2001 From: riefive Date: Tue, 16 Sep 2025 12:34:37 +0700 Subject: [PATCH 02/14] feat(public): add setting dark or light mode --- app/components/layout/Header.vue | 9 +++++--- app/components/layout/ThemeToggle.vue | 15 +++++++++++++ app/composables/useTheme.ts | 31 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 app/components/layout/ThemeToggle.vue create mode 100644 app/composables/useTheme.ts diff --git a/app/components/layout/Header.vue b/app/components/layout/Header.vue index 369b29ea..45dd38cd 100644 --- a/app/components/layout/Header.vue +++ b/app/components/layout/Header.vue @@ -1,4 +1,6 @@ + + diff --git a/app/composables/useTheme.ts b/app/composables/useTheme.ts new file mode 100644 index 00000000..f028d4c6 --- /dev/null +++ b/app/composables/useTheme.ts @@ -0,0 +1,31 @@ +import { ref, watchEffect } from 'vue' + +const THEME_KEY = 'theme-mode' + +export function useTheme() { + const theme = ref<'light' | 'dark'>(getInitialTheme()) + + function getInitialTheme() { + if (typeof window === 'undefined') return 'light' + const persisted = localStorage.getItem(THEME_KEY) + if (persisted === 'dark' || persisted === 'light') return persisted + // fallback: system preference + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + + function setTheme(newTheme: 'light' | 'dark') { + theme.value = newTheme + localStorage.setItem(THEME_KEY, newTheme) + document.documentElement.classList.toggle('dark', newTheme === 'dark') + } + + function toggleTheme() { + setTheme(theme.value === 'dark' ? 'light' : 'dark') + } + + watchEffect(() => { + setTheme(theme.value) + }) + + return { theme, toggleTheme } +} From 01057b9138617bfba0a9c325b0e635af77c00649 Mon Sep 17 00:00:00 2001 From: riefive Date: Tue, 16 Sep 2025 12:51:36 +0700 Subject: [PATCH 03/14] feat(public): implement dark mode --- app/assets/css/main.css | 72 +++++++++++----------- app/components/content/dashboard/index.vue | 16 ++--- app/components/layout/ThemeToggle.vue | 6 +- app/layouts/default.vue | 13 ++-- 4 files changed, 55 insertions(+), 52 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 4c488385..7aab15b5 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -67,41 +67,43 @@ --sidebar-accent-foreground: 240 5.9% 10%; --sidebar-border: 220 13% 91%; --sidebar-ring: 217.2 91.2% 59.8%; - /* .dark { */ - /* --background: 210 25% 8%; */ - /* --foreground: 210 20% 95%; */ - /* --card: 210 25% 10%; */ - /* --card-foreground: 210 20% 95%; */ - /* --popover: 210 25% 10%; */ - /* --popover-foreground: 210 20% 95%; */ - /* --primary: 150 75% 45%; */ - /* --primary-foreground: 0 0% 100%; */ - /* --primary-hover: 150 75% 50%; */ - /* --secondary: 210 25% 15%; */ - /* --secondary-foreground: 210 20% 90%; */ - /* --muted: 210 25% 15%; */ - /* --muted-foreground: 210 15% 65%; */ - /* --accent: 210 100% 55%; */ - /* --accent-foreground: 0 0% 100%; */ - /* --destructive: 0 75% 60%; */ - /* --destructive-foreground: 0 0% 100%; */ - /* --border: 210 25% 20%; */ - /* --input: 210 25% 15%; */ - /* --ring: 150 75% 45%; */ - /* --success: 150 75% 50%; */ - /* --warning: 45 95% 65%; */ - /* --info: 210 100% 60%; */ - /* --gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%)); */ - /* --gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%)); */ - /* --gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%)); */ - /* --sidebar-background: 240 5.9% 10%; */ - /* --sidebar-foreground: 240 4.8% 95.9%; */ - /* --sidebar-primary: 224.3 76.3% 48%; */ - /* --sidebar-primary-foreground: 0 0% 100%; */ - /* --sidebar-accent: 240 3.7% 15.9%; */ - /* --sidebar-accent-foreground: 240 4.8% 95.9%; */ - /* --sidebar-border: 240 3.7% 15.9%; */ - /* --sidebar-ring: 217.2 91.2% 59.8%; */ +} + +.dark { + --background: 210 25% 8%; + --foreground: 210 20% 95%; + --card: 210 25% 10%; + --card-foreground: 210 20% 95%; + --popover: 210 25% 10%; + --popover-foreground: 210 20% 95%; + --primary: 150 75% 45%; + --primary-foreground: 0 0% 100%; + --primary-hover: 150 75% 50%; + --secondary: 210 25% 15%; + --secondary-foreground: 210 20% 90%; + --muted: 210 25% 15%; + --muted-foreground: 210 15% 65%; + --accent: 210 100% 55%; + --accent-foreground: 0 0% 100%; + --destructive: 0 75% 60%; + --destructive-foreground: 0 0% 100%; + --border: 210 25% 20%; + --input: 210 25% 15%; + --ring: 150 75% 45%; + --success: 150 75% 50%; + --warning: 45 95% 65%; + --info: 210 100% 60%; + --gradient-primary: linear-gradient(135deg, hsl(150 75% 45%), hsl(150 75% 55%)); + --gradient-medical: linear-gradient(135deg, hsl(150 75% 45%), hsl(210 100% 55%)); + --gradient-subtle: linear-gradient(180deg, hsl(210 25% 8%), hsl(210 25% 12%)); + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } /* Keyframes for Animations */ diff --git a/app/components/content/dashboard/index.vue b/app/components/content/dashboard/index.vue index 2195b428..80809852 100644 --- a/app/components/content/dashboard/index.vue +++ b/app/components/content/dashboard/index.vue @@ -113,7 +113,7 @@ onMounted(() => {

Dashboard SIMRS

-
Status: Aktif
+
Status: Aktif
@@ -156,19 +156,19 @@ onMounted(() => {
- +

Aksi Cepat

+ v-for="item in linkItems" + :key="item.title" + class="my-2 h-32 border border-primary transition-colors duration-200 hover:bg-gray-200 hover:bg-primary" + > - +

{{ item.title }}

diff --git a/app/components/layout/ThemeToggle.vue b/app/components/layout/ThemeToggle.vue index 5310ddcc..d59dd19f 100644 --- a/app/components/layout/ThemeToggle.vue +++ b/app/components/layout/ThemeToggle.vue @@ -6,10 +6,10 @@ const { theme, toggleTheme } = useTheme() diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 7f5959b7..7bce9c00 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -56,9 +56,9 @@ const contentContent = computed(() => { margin-right: auto; padding: 0.75rem; /* p-3 */ padding-bottom: 5rem; /* pb-20 */ - border-width: 1px; - background-color: white !important; + background-color: hsl(var(--background)); border-radius: 0.375rem; + border: 1px solid hsl(var(--border)); border-color: rgb(226 232 240); /* slate-200 */ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } @@ -78,18 +78,19 @@ const contentContent = computed(() => { .cf-frame-width { margin-left: auto; margin-right: auto; - background-color: white; - border: 1px solid rgb(226 232 240); + background-color: hsl(var(--background)); border-radius: 0.375rem; + border: 1px solid hsl(var(--border)); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } + .cf-frame { margin-left: auto; margin-right: auto; padding: 0.75rem; - background-color: white; + background-color: hsl(var(--background)); border-radius: 0.375rem; - border: 1px solid rgb(226 232 240); + border: 1px solid hsl(var(--border)); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } From e5fe4d1c96bdbcd7ce141bbe24bb2faf3a35deb7 Mon Sep 17 00:00:00 2001 From: riefive Date: Tue, 16 Sep 2025 12:59:11 +0700 Subject: [PATCH 04/14] feat(public): frame padding for dashboard --- app/components/content/dashboard/index.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/content/dashboard/index.vue b/app/components/content/dashboard/index.vue index 80809852..e2fe0993 100644 --- a/app/components/content/dashboard/index.vue +++ b/app/components/content/dashboard/index.vue @@ -121,10 +121,10 @@ onMounted(() => {
-
+
-
+
Recent Sales @@ -160,7 +160,7 @@ onMounted(() => {

Aksi Cepat

- + Date: Tue, 16 Sep 2025 15:14:37 +0700 Subject: [PATCH 05/14] fix(select-tree): adjust tree node indentation and alignment logic - Add level prop to track node hierarchy - Fix indentation calculation for leaves and nodes - Simplify alignment logic based on node level --- app/components/pub/base/select-tree/leaf.vue | 2 +- .../pub/base/select-tree/tree-node.vue | 11 +++-------- .../pub/base/select-tree/tree-select.vue | 1 + .../pub/base/select-tree/tree-view.vue | 16 +++++++++++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/components/pub/base/select-tree/leaf.vue b/app/components/pub/base/select-tree/leaf.vue index 1dc8c892..81dabf2b 100644 --- a/app/components/pub/base/select-tree/leaf.vue +++ b/app/components/pub/base/select-tree/leaf.vue @@ -21,7 +21,7 @@ function handleSelect(value: string) { {{ item.label }} diff --git a/app/components/pub/base/select-tree/tree-node.vue b/app/components/pub/base/select-tree/tree-node.vue index da7a0bdf..e390b7bb 100644 --- a/app/components/pub/base/select-tree/tree-node.vue +++ b/app/components/pub/base/select-tree/tree-node.vue @@ -7,14 +7,13 @@ const props = defineProps<{ item: TreeItem selectedValue?: string onFetchChildren: (parentId: string) => Promise + level?: number }>() const emit = defineEmits(['select']) -// Computed untuk memastikan reactivity pada children const hasChildren = computed(() => props.item.children && props.item.children.length > 0) -// State terpisah untuk chevron animation dan loading const isOpen = ref(false) const isLoading = ref(false) const isChevronRotated = ref(false) @@ -27,14 +26,11 @@ function handleLabelClick() { handleSelect(props.item.value) } -// Watch untuk handle fetch data ketika collapsible dibuka watch(isOpen, async (newValue) => { console.log(`[TreeNode] ${props.item.label} - isOpen changed to:`, newValue) - // Update chevron rotation berdasarkan open state isChevronRotated.value = newValue - // Jika membuka dan belum ada children, fetch data if (newValue && props.item.hasChildren && !props.item.children && !isLoading.value) { console.log(`[TreeNode] Fetching children for: ${props.item.label}`) isLoading.value = true @@ -65,9 +61,7 @@ watch(isOpen, async (newValue) => { variant="ghost" class="h-4 w-4 p-0 flex items-center justify-center" > - - {
- +
{{ isLoading ? 'Memuat...' : 'Tidak ada data' }}
@@ -102,6 +96,7 @@ watch(isOpen, async (newValue) => { :data="item.children!" :selected-value="selectedValue" :on-fetch-children="onFetchChildren" + :level="(level || 0) + 1" @select="handleSelect" />
diff --git a/app/components/pub/base/select-tree/tree-select.vue b/app/components/pub/base/select-tree/tree-select.vue index 3a7cafd8..d0525336 100644 --- a/app/components/pub/base/select-tree/tree-select.vue +++ b/app/components/pub/base/select-tree/tree-select.vue @@ -58,6 +58,7 @@ const selectedLabel = computed(() => { :data="data" :selected-value="modelValue" :on-fetch-children="onFetchChildren" + :level="0" @select="handleSelect" /> diff --git a/app/components/pub/base/select-tree/tree-view.vue b/app/components/pub/base/select-tree/tree-view.vue index abe6c0de..dfc51d5c 100644 --- a/app/components/pub/base/select-tree/tree-view.vue +++ b/app/components/pub/base/select-tree/tree-view.vue @@ -7,6 +7,7 @@ const props = defineProps<{ data: TreeItem[] selectedValue?: string onFetchChildren: (parentId: string) => Promise + level?: number }>() const emit = defineEmits(['select']) @@ -19,25 +20,34 @@ function handleSelect(value: string) { const hasAnyChildrenInLevel = computed(() => { return props.data.some(item => item.hasChildren) }) + +// Computed untuk menentukan apakah perlu alignment berdasarkan level +const shouldAlignLeaves = computed(() => { + // Di root level (level 0), selalu align leaf dengan tree nodes jika ada mixed content + // Di level lain, hanya align jika ada mixed content + const isRootLevel = (props.level || 0) === 0 + const hasMixedContent = hasAnyChildrenInLevel.value && props.data.some(item => !item.hasChildren) + + return isRootLevel ? hasAnyChildrenInLevel.value : hasMixedContent +})