<template>
<section class="page">
<AppLoadingState v-if="areasStore.isLoading || areasStore.isLoadingAreaDetail" text="Loading area" />
<AppErrorState
v-else-if="areasStore.error && !areasStore.areas.length"
title="Areas loading failed"
:message="areasStore.error.message"
:retry="init"
/>
<AppErrorState
v-else-if="areasStore.errorAreaDetail"
title="Area loading failed"
:message="areasStore.errorAreaDetail.message"
:retry="init"
/>
<div v-else-if="area">
<GnPageHeader :title="area.display_name" kicker="Area">
<template #actions>
<GnButton variant="secondary" icon="ph-pencil" @click="openRename">Rename</GnButton>
<GnButton
variant="primary"
icon="ph-map-pin"
@click="openAssign"
>
{{ area.parent_id > 0 ? 'Change parent area' : 'Assign to area' }}
</GnButton>
<GnButton
:variant="isFavorite ? 'warning' : 'secondary'"
:icon="isFavorite ? 'ph-star' : 'ph-star'"
@click="favoritesStore.toggle(area.id)"
>
{{ isFavorite ? 'Unstar' : 'Star' }}
</GnButton>
<GnButton variant="danger" icon="ph-trash" @click="openRemove">Remove</GnButton>
</template>
</GnPageHeader>
<div class="area-meta">
<GnBadge variant="secondary">{{ area.type }}</GnBadge>
<code>{{ area.alias }}</code>
<AreaBadgeLink :area="parentArea" :areaId="area.parent_id" />
</div>
<AreaAssignSection
:areaId="area.parent_id > 0 ? area.parent_id : null"
title="Parent area"
emptyMessage="This area is not assigned to any parent area."
@assign="openAssign"
>
<template #action>
<GnButton
variant="primary"
icon="ph-map-pin"
@click="openAssign"
>
{{ area.parent_id > 0 ? 'Change parent area' : 'Assign to area' }}
</GnButton>
</template>
</AreaAssignSection>
<div class="devices-panel">
<div class="block-title">Devices ({{ areasStore.currentAreaDevices.length }})</div>
<AppEmptyState
v-if="areasStore.currentAreaDevices.length === 0"
title="No devices"
message="No devices assigned to this area."
/>
<GnTable v-else :rows="areasStore.currentAreaDevices" :columns="deviceColumns">
<template #cell-actions="{ row }">
<router-link :to="{ name: 'devices' }" class="area-link">View device</router-link>
</template>
</GnTable>
</div>
<div class="devices-panel">
<div class="block-title">Scripts ({{ areasStore.currentAreaScripts.length }})</div>
<AppEmptyState
v-if="areasStore.currentAreaScripts.length === 0"
title="No scripts"
message="No scripts assigned to this area."
/>
<GnTable v-else :rows="areasStore.currentAreaScripts" :columns="scriptColumns">
<template #cell-actions="{ row }">
<router-link :to="{ name: 'scripts-actions' }" class="area-link">View script</router-link>
</template>
</GnTable>
</div>
</div>
<AppEmptyState
v-else
title="Area not found"
message="The requested area does not exist."
/>
<GnModal :open="showRenameModal" title="Rename area" @update:open="showRenameModal = $event">
<div class="form-group">
<GnInput v-model="renameForm.display_name" label="Display name" />
</div>
<div v-if="renameError" class="form-group">
<GnAlert variant="danger">{{ renameError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showRenameModal = false">Cancel</GnButton>
<GnButton variant="primary" icon="ph-check" :loading="renameLoading" @click="submitRename">Rename</GnButton>
</template>
</GnModal>
<GnModal
:open="showAssignModal"
title="Assign to parent area"
@update:open="showAssignModal = $event"
>
<GnSelect
v-model="selectedAreaId"
label="Parent area"
:options="parentAreaOptions"
icon="ph-map-trifold"
/>
<div v-if="assignError" class="form-group">
<GnAlert variant="danger">{{ assignError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showAssignModal = false">Cancel</GnButton>
<GnButton
variant="primary"
icon="ph-check"
:loading="assignLoading"
@click="submitAssign"
>
Assign
</GnButton>
</template>
</GnModal>
<GnConfirmDialog
:open="showRemoveDialog"
title="Remove area"
:message="removeDialogMessage"
confirm-text="Remove"
cancel-text="Cancel"
confirm-variant="danger"
@update:open="showRemoveDialog = $event"
@confirm="submitRemove"
/>
</section>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAreasStore } from "../../../stores/areas";
import { useFavoritesStore } from "../../../stores/favorites";
import { useAreaAssign } from "../../../composables/useAreaAssign";
import {
GnPageHeader,
GnButton,
GnBadge,
GnModal,
GnInput,
GnAlert,
GnTable,
GnSelect,
GnConfirmDialog,
} from "gnexus-ui-kit/vue";
import AppLoadingState from "../../../components/feedback/AppLoadingState.vue";
import AppErrorState from "../../../components/feedback/AppErrorState.vue";
import AppEmptyState from "../../../components/feedback/AppEmptyState.vue";
import AreaBadgeLink from "../../../components/area/AreaBadgeLink.vue";
import AreaAssignSection from "../../../components/area/AreaAssignSection.vue";
const route = useRoute();
const router = useRouter();
const areasStore = useAreasStore();
const favoritesStore = useFavoritesStore();
const {
showAssignModal,
selectedAreaId,
assignLoading,
assignError,
openAssign: openAssignModal,
submitAssign: submitAssignCore,
} = useAreaAssign();
const area = computed(() => areasStore.areasById[String(route.params.id)] || null);
const isFavorite = computed(() => (area.value ? favoritesStore.has(area.value.id) : false));
const parentArea = computed(() => {
if (!area.value || area.value.parent_id <= 0) return null;
return areasStore.areasById[String(area.value.parent_id)] || null;
});
function getDescendantIds(areaId) {
const result = new Set();
const queue = [areaId];
while (queue.length) {
const current = queue.shift();
const children = areasStore.areas.filter((a) => a.parent_id === current);
for (const child of children) {
result.add(child.id);
queue.push(child.id);
}
}
return result;
}
const parentAreaOptions = computed(() => {
if (!area.value) return [];
const excluded = new Set([area.value.id, ...getDescendantIds(area.value.id)]);
return areasStore.areas
.filter((a) => !excluded.has(a.id))
.map((a) => ({
value: String(a.id),
label: `${a.display_name} (${a.type})`,
}));
});
const showRenameModal = ref(false);
const renameLoading = ref(false);
const renameError = ref("");
const renameForm = reactive({ areaId: null, display_name: "" });
const showRemoveDialog = ref(false);
const removeDialogMessage = ref("");
const deviceColumns = [
{ key: "alias", label: "Alias" },
{ key: "name", label: "Name" },
{ key: "device_type", label: "Type" },
];
const scriptColumns = [
{ key: "alias", label: "Alias" },
{ key: "name", label: "Name" },
{ key: "state", label: "State" },
];
function openRename() {
if (!area.value) return;
renameForm.areaId = area.value.id;
renameForm.display_name = area.value.display_name;
renameError.value = "";
showRenameModal.value = true;
}
async function submitRename() {
renameLoading.value = true;
renameError.value = "";
const result = await areasStore.renameArea(renameForm.areaId, renameForm.display_name);
renameLoading.value = false;
if (!result.ok) {
renameError.value = result.error?.message || "Failed to rename area";
return;
}
showRenameModal.value = false;
}
function openRemove() {
if (!area.value) return;
removeDialogMessage.value = `Are you sure you want to remove area "${area.value.display_name}"?`;
showRemoveDialog.value = true;
}
async function submitRemove() {
if (!area.value) return;
await areasStore.removeArea(area.value.id);
router.push({ name: "areas-tree" });
}
function openAssign() {
openAssignModal(area.value?.parent_id > 0 ? area.value.parent_id : "");
}
async function submitAssign() {
const id = area.value?.id;
await submitAssignCore(id, (itemId, parentId) => areasStore.assignToArea(itemId, parentId));
}
async function unassign() {
if (!area.value) return;
await areasStore.unassignArea(area.value.id);
}
async function init() {
const id = route.params.id;
if (!id) return;
if (areasStore.areas.length === 0) {
await areasStore.loadAreas();
}
if (areasStore.areasById[String(id)]) {
await areasStore.loadAreaDetail(id);
}
}
onMounted(() => {
init();
});
onUnmounted(() => {
areasStore.clearAreaDetail();
});
</script>
<style scoped>
.area-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 24px;
}
.area-parent {
color: var(--color-muted);
}
.devices-panel {
margin-bottom: 24px;
}
.block-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 12px;
color: var(--color-primary);
}
.form-group {
margin-bottom: 16px;
}
.area-link {
color: var(--color-primary);
}
</style>