diff --git a/webclient-vue/src/features/areas/components/AreaTreeNode.vue b/webclient-vue/src/features/areas/components/AreaTreeNode.vue new file mode 100644 index 0000000..0af5f1a --- /dev/null +++ b/webclient-vue/src/features/areas/components/AreaTreeNode.vue @@ -0,0 +1,71 @@ + + + diff --git a/webclient-vue/src/features/areas/pages/AreaTreePage.vue b/webclient-vue/src/features/areas/pages/AreaTreePage.vue new file mode 100644 index 0000000..e401b97 --- /dev/null +++ b/webclient-vue/src/features/areas/pages/AreaTreePage.vue @@ -0,0 +1,46 @@ + + + diff --git a/webclient-vue/src/router/routes.js b/webclient-vue/src/router/routes.js index 70c07c5..91872ef 100644 --- a/webclient-vue/src/router/routes.js +++ b/webclient-vue/src/router/routes.js @@ -1,4 +1,5 @@ import AreaFavoritesPage from "../features/areas/pages/AreaFavoritesPage.vue"; +import AreaTreePage from "../features/areas/pages/AreaTreePage.vue"; export const routes = [ { @@ -11,6 +12,11 @@ component: AreaFavoritesPage, }, { + path: "/areas/tree", + name: "areas-tree", + component: AreaTreePage, + }, + { path: "/:pathMatch(.*)*", name: "not-found", component: () => import("../features/system/NotFoundPage.vue"), diff --git a/webclient-vue/src/stores/areas.js b/webclient-vue/src/stores/areas.js index 401d931..654ded6 100644 --- a/webclient-vue/src/stores/areas.js +++ b/webclient-vue/src/stores/areas.js @@ -1,6 +1,33 @@ import { defineStore } from "pinia"; import { areasApi } from "../api/modules/areas"; +function buildAreaTree(areas) { + const map = {}; + const roots = []; + + for (const area of areas) { + map[area.id] = { ...area, children: [] }; + } + + for (const area of areas) { + const node = map[area.id]; + const isSelfReference = area.parent_id && area.parent_id == area.id; + const parentExists = area.parent_id && map[area.parent_id]; + + if (!isSelfReference && parentExists) { + map[area.parent_id].children.push(node); + } else { + roots.push(node); + } + } + + if (roots.length === 0 && areas.length > 0) { + return Object.values(map); + } + + return roots; +} + export const useAreasStore = defineStore("areas", { state: () => ({ areas: [], @@ -12,6 +39,10 @@ areasById(state) { return Object.fromEntries(state.areas.map((area) => [String(area.id), area])); }, + + areaTree(state) { + return buildAreaTree(state.areas); + }, }, actions: { diff --git a/webclient-vue/src/styles/main.css b/webclient-vue/src/styles/main.css index a32c924..4ad54b5 100644 --- a/webclient-vue/src/styles/main.css +++ b/webclient-vue/src/styles/main.css @@ -139,6 +139,66 @@ color: var(--color-muted); } +.area-tree, +.area-tree-children { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.area-tree-children { + margin-top: 12px; + padding-left: 28px; +} + +.area-tree-card { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 14px; + padding: 14px; + border: var(--border); + background: var(--color-panel); +} + +.area-tree-cycle { + color: var(--color-danger); +} + +.tree-toggle { + width: 36px; + height: 36px; + border: 2px solid currentColor; + background: transparent; + color: var(--color-primary); + cursor: pointer; + font-weight: 800; +} + +.tree-toggle:disabled { + color: var(--color-muted); + cursor: default; +} + +.area-tree-info h2 { + margin: 0 0 8px; + font-size: 20px; +} + +.area-tree-info p, +.area-tree-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; +} + +.area-tree-info p { + color: var(--color-muted); +} + .ui-button { display: inline-flex; align-items: center; @@ -157,6 +217,10 @@ color: var(--color-warning); } +.ui-button-warning { + color: var(--color-accent); +} + .ui-button-danger { color: var(--color-danger); } @@ -219,4 +283,12 @@ align-items: stretch; flex-direction: column; } + + .area-tree-card { + grid-template-columns: 1fr; + } + + .area-tree-children { + padding-left: 14px; + } }