Newer
Older
smart-home-server / webclient-vue / src / features / areas / pages / AreaDetailPage.vue
<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="isLastRoot ? 'This is the last root area and must remain unassigned.' : 'This area is not assigned to any parent area.'"
        @assign="openAssign"
      >
        <template #action>
          <GnButton
            v-if="!isLastRoot"
            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-model="selectedAreaId"
        label="Parent area"
        :options="parentAreaOptions"
        icon="ph-map-trifold"
      />
      <div v-if="assignError || unassignError" class="form-group">
        <GnAlert variant="danger">{{ assignError || unassignError }}</GnAlert>
      </div>
      <template #footer>
        <GnButton variant="secondary" @click="showAssignModal = false">Cancel</GnButton>
        <GnButton
          v-if="area?.parent_id > 0"
          variant="warning"
          icon="ph-map-pin-slash"
          :loading="unassignLoading"
          @click="handleUnassignSubmit"
        >
          Unassign
        </GnButton>
        <GnButton
          variant="primary"
          icon="ph-check"
          :loading="assignLoading"
          @click="handleAssignSubmit"
        >
          Assign
        </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,
  unassignLoading,
  unassignError,
  openAssign: openAssignModal,
  submitAssignCore,
  submitUnassignCore,
} = 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-map-pin-slash", onSelect: handleUnassign }
    );
  } else if (!isLastRoot.value) {
    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("");

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" });
  }
}

async function handleUnassign() {
  if (!area.value) return;
  await handleUnassignSubmit();
}

async function handleUnassignSubmit() {
  const id = area.value?.id;
  const result = await submitUnassignCore(id, areasStore.unassignArea.bind(areasStore));
  if (result?.ok) {
    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>