<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"
:error="areasStore.error"
:retry="init"
/>
<AppErrorState
v-else-if="areasStore.errorAreaDetail"
title="Area loading failed"
:error="areasStore.errorAreaDetail"
:retry="init"
/>
<div v-else-if="area">
<GnPageHeader :title="area.display_name" kicker="Area">
<template #actions>
<PageActionsDropdown :items="areaActions" />
</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."
/>
<DeviceTable v-else :devices="areasStore.currentAreaDevices" caption="Area devices" />
</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."
/>
<ScriptTable
v-else
:scripts="areasStore.currentAreaScripts"
scriptType="regular"
:showArea="false"
:showActions="false"
:showFilename="false"
caption="Area scripts"
/>
</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-if="!isLastRoot"
v-model="selectedAreaId"
label="Parent area"
:options="parentAreaOptions"
icon="ph-map-trifold"
/>
<div v-if="isLastRoot" class="form-group">
<GnAlert variant="warning">
This is the last root area and cannot be assigned as a child.
At least one root area must remain.
</GnAlert>
</div>
<div v-if="assignError" class="form-group">
<GnAlert variant="danger">{{ assignError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showAssignModal = false">Cancel</GnButton>
<GnButton
v-if="!isLastRoot"
variant="primary"
icon="ph-check"
:loading="assignLoading"
@click="handleAssignSubmit"
>
Assign
</GnButton>
</template>
</GnModal>
<GnModal
:open="showUnassignDialog"
title="Unassign from parent"
@update:open="showUnassignDialog = $event"
>
<p>{{ unassignDialogMessage }}</p>
<div v-if="unassignError" class="form-group">
<GnAlert variant="danger">{{ unassignError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showUnassignDialog = false">Cancel</GnButton>
<GnButton variant="warning" icon="ph-x-circle" :loading="unassignLoading" @click="submitUnassign">
Unassign
</GnButton>
</template>
</GnModal>
<GnModal
:open="showRemoveDialog"
title="Remove area"
@update:open="showRemoveDialog = $event"
>
<p>{{ removeDialogMessage }}</p>
<div v-if="removeError" class="form-group">
<GnAlert variant="danger">{{ removeError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showRemoveDialog = false">Cancel</GnButton>
<GnButton variant="danger" icon="ph-trash" :loading="removeLoading" @click="submitRemove">
Remove
</GnButton>
</template>
</GnModal>
</section>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAreasStore } from "../../../stores/areas";
import { useDevicesStore } from "../../../stores/devices";
import { useFavoritesStore } from "../../../stores/favorites";
import { useAreaAssign } from "../../../composables/useAreaAssign";
import {
GnPageHeader,
GnButton,
GnBadge,
GnModal,
GnInput,
GnAlert,
GnSelect,
useToast,
} 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";
import DeviceTable from "../../../components/device/DeviceTable.vue";
import ScriptTable from "../../../components/script/ScriptTable.vue";
import PageActionsDropdown from "../../../components/layout/PageActionsDropdown.vue";
const route = useRoute();
const router = useRouter();
const areasStore = useAreasStore();
const devicesStore = useDevicesStore();
const favoritesStore = useFavoritesStore();
const toast = useToast();
const {
showAssignModal,
selectedAreaId,
assignLoading,
assignError,
openAssign: openAssignModal,
submitAssignCore,
} = useAreaAssign();
const area = computed(() => areasStore.areasById[String(route.params.id)] || null);
const isFavorite = computed(() => (area.value ? favoritesStore.has(area.value.id) : false));
const areaActions = computed(() => {
const actions = [{ label: "Rename", icon: "ph-pencil", onSelect: openRename }];
if (area.value?.parent_id > 0) {
actions.push(
{ label: "Change parent area", icon: "ph-map-pin", onSelect: openAssign },
{ label: "Unassign from parent", icon: "ph-x-circle", onSelect: openUnassign }
);
} else {
actions.push({
label: "Assign to area",
icon: "ph-map-pin",
onSelect: openAssign,
});
}
actions.push(
{
label: isFavorite.value ? "Remove bookmark" : "Bookmark",
icon: isFavorite.value ? "ph-fill ph-bookmark-simple" : "ph-bookmark-simple",
onSelect: () => favoritesStore.toggle(area.value?.id),
},
{ label: "Remove", icon: "ph-trash", danger: true, onSelect: openRemove }
);
return actions;
});
const parentArea = computed(() => {
if (!area.value || area.value.parent_id <= 0) return null;
return areasStore.areasById[String(area.value.parent_id)] || null;
});
const isLastRoot = computed(() => {
if (!area.value) return false;
const isRoot = !area.value.parent_id || area.value.parent_id <= 0;
const rootCount = areasStore.areas.filter((a) => !a.parent_id || a.parent_id <= 0).length;
return isRoot && rootCount === 1;
});
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 removeLoading = ref(false);
const removeError = ref("");
const showUnassignDialog = ref(false);
const unassignDialogMessage = ref("");
const unassignLoading = ref(false);
const unassignError = ref("");
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;
toast.success({ title: "Renamed", text: "Area renamed successfully" });
}
function openRemove() {
if (!area.value) return;
removeDialogMessage.value = `Are you sure you want to remove area "${area.value.display_name}"?`;
removeError.value = "";
showRemoveDialog.value = true;
}
async function submitRemove() {
if (!area.value) return;
removeLoading.value = true;
removeError.value = "";
const result = await areasStore.removeArea(area.value.id);
removeLoading.value = false;
if (!result.ok) {
removeError.value = result.error?.message || "Failed to remove area";
return;
}
showRemoveDialog.value = false;
toast.success({ title: "Removed", text: "Area removed successfully" });
router.push({ name: "areas-tree" });
}
function openAssign() {
console.log("[AreaDetailPage] openAssign called");
openAssignModal(area.value?.parent_id > 0 ? area.value.parent_id : "");
}
async function handleAssignSubmit() {
const id = area.value?.id;
const result = await submitAssignCore(id, (itemId, parentId) => areasStore.assignToArea(itemId, parentId));
if (result?.ok) {
toast.success({ title: "Assigned", text: "Area assigned successfully" });
}
}
function openUnassign() {
if (!area.value) return;
unassignDialogMessage.value = `Are you sure you want to unassign area "${area.value.display_name}" from its parent?`;
unassignError.value = "";
showUnassignDialog.value = true;
}
async function submitUnassign() {
if (!area.value) return;
unassignLoading.value = true;
unassignError.value = "";
const result = await areasStore.unassignArea(area.value.id);
unassignLoading.value = false;
if (!result.ok) {
unassignError.value = result.error?.message || "Failed to unassign area";
return;
}
showUnassignDialog.value = false;
toast.success({ title: "Unassigned", text: "Area unassigned from parent successfully" });
}
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);
if (areasStore.currentAreaDevices.length > 0) {
await devicesStore.loadStatesFor(areasStore.currentAreaDevices);
}
}
}
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>