Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<template>
  <BaseLayout title="Directory">
    <div class="directory-container">
      <h1 class="page-title">What are we doing today?</h1>

      <SearchBar
        v-if="hasAnyInput"
        title=""
        button-text="Search"
        :search-terms="searchTerms"
        :selected-tags="selectedTags"
        :available-tags="availableTags"
        :suggestions="suggestions"
        :show-clear="hasAnyInput"
        :show-tags="hasCategoryOrTagFilter"
        :category="selectedCategory"
        :top-level="selectedTopLevel"
        :quick-access-items="hasTopLevelOnly ? quickAccessProblems : []"
        navigate-path="/directory"
        @navigate="handleNavigate"
        @search-input="handleSearchInput"
        @quick-access-click="selectProblem"
      />

      <div v-if="loading" class="loading-message">
        Loading directory...
      </div>

      <template v-else>
        <!-- Breadcrumb navigation -->
        <div v-if="hasAnyInput" class="breadcrumb-row">
          <div class="breadcrumbs">
            <!-- Text search breadcrumb -->
            <template v-if="hasTextSearch">
              <span class="breadcrumb-item search-crumb">
                Results for "{{ searchTerms.join(' ') }}"
              </span>
            </template>

            <!-- Category/tag breadcrumbs -->
            <template v-else>
              <span
                v-if="selectedTopLevelName"
                class="breadcrumb-item clickable"
                @click="clickTopLevelBreadcrumb"
              >
                {{ selectedTopLevelName }}
              </span>
              <span
                v-if="selectedCategoryName"
                class="breadcrumb-item clickable"
                @click="clickCategoryBreadcrumb"
              >
                {{ selectedCategoryName }}
              </span>
              <span
                v-for="(tag, index) in selectedTags"
                :key="tag.id"
                class="breadcrumb-item clickable"
                @click="clickTagBreadcrumb(index)"
              >
                {{ tag.name }}
              </span>
            </template>
          </div>
        </div>

        <!-- Text search: show searchResults -->
        <template v-if="hasTextSearch">
          <div v-if="directoryData.searchResults.length === 0" class="no-results-message">
            <p>No jobs match your search.</p>
          </div>
          <div v-else class="search-results">
            <SearchResult
              v-for="problem in directoryData.searchResults"
              :key="problem.id"
              :title="problem.name || ''"
              :description="problem.description || ''"
              @click="selectProblem(problem)"
            />
          </div>
        </template>

        <!-- Category/tag filtering: show categoryView -->
        <template v-else-if="hasCategoryOrTagFilter">
          <div v-if="categoryViewProblems.length === 0" class="no-results-message">
            <p>No jobs found in this category.</p>
          </div>
          <div v-else class="search-results">
            <SearchResult
              v-for="problem in categoryViewProblems"
              :key="problem.id"
              :title="problem.name || ''"
              :description="problem.description || ''"
              @click="selectProblemFromCategory(problem)"
            />
          </div>
        </template>

        <!-- Top-level selected but Plumbing: show database categories -->
        <template v-else-if="hasTopLevelOnly && selectedTopLevel === '_plumbing'">
          <div v-if="sortedCategories.length === 0" class="no-results-message">
            <p>No categories found.</p>
          </div>
          <div v-else class="categories-list">
            <div
              v-for="category in sortedCategories"
              :key="category.id"
              class="category-item"
              @click="selectCategory(category)"
            >
              <span class="category-name">{{ category.name }}</span>
            </div>
          </div>
        </template>

        <!-- Top-level selected but not Plumbing: coming soon -->
        <template v-else-if="hasTopLevelOnly">
          <div class="no-results-message">
            <p>{{ selectedTopLevelName }} coming soon.</p>
          </div>
        </template>

        <!-- No input: show top-level categories as 2x2 grid -->
        <template v-else>
          <div class="top-level-grid">
            <div
              v-for="category in TOP_LEVEL_CATEGORIES"
              :key="category.id"
              :class="['top-level-card', { 'top-level-card-disabled': !category.enabled }]"
              @click="category.enabled && selectTopLevel(category)"
            >
              <span class="top-level-name">{{ category.name }}</span>
              <button
                class="top-level-button"
                :disabled="!category.enabled"
                @click.stop="category.enabled && selectTopLevel(category)"
              >
                Access Menu Pricing
              </button>
            </div>
          </div>
        </template>
      </template>
    </div>
  </BaseLayout>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import BaseLayout from "@/components/BaseLayout.vue";
import SearchBar from "@/components/SearchBar.vue";
import SearchResult from "@/components/SearchResult.vue";
import { loadDirectory, type DirectoryData, type ProblemWithTagNames } from "@/framework/directory";
import type { ProblemCategoriesRecord, ProblemsRecord } from "@/pocketbase-types";
import { useTnfrStore } from "@/stores/tnfr";

const router = useRouter();
const route = useRoute();
const tnfrStore = useTnfrStore();
const loading = ref(true);
const directoryData = ref<DirectoryData>({
  problems: [],
  tags: [],
  categories: [],
  searchResults: [],
  categoryView: [],
  availableTags: [],
});

// HACK: Hard-coded top-level categories (will be in database later)
const TOP_LEVEL_CATEGORIES = [
  { id: '_plumbing', name: 'Plumbing', enabled: true },
  { id: '_electrical', name: 'Electrical', enabled: false },
  { id: '_hvac_service', name: 'HVAC Service', enabled: false },
  { id: '_hvac_equipment', name: 'HVAC Equipment', enabled: false },
];

// Fallback quick access problem refIds (used if speedDial has < 5 items)
const FALLBACK_QUICK_ACCESS_REFIDS = [
  'PE3_problem',
  'PC1_problem',
  'PA1_problem',
  'PH7_problem',
  'PG14_problem',
];

// Filter problems for quick access chips - use speedDial or fallback to hardcoded
const quickAccessProblems = computed(() => {
  const refIds = tnfrStore.speedDial.length >= 5
    ? tnfrStore.speedDial.slice(0, 5)
    : FALLBACK_QUICK_ACCESS_REFIDS;

  return refIds
    .map(refId => directoryData.value.problems.find(p => p.refId === refId))
    .filter((p): p is ProblemsRecord => p !== undefined)
    .map(p => ({ id: p.id, name: p.name || 'Unnamed', refId: p.refId }));
});

const selectedTopLevel = computed(() => {
  return route.query.top?.toString() || null;
});

const selectedTopLevelName = computed(() => {
  if (!selectedTopLevel.value) return null;
  const cat = TOP_LEVEL_CATEGORIES.find(c => c.id === selectedTopLevel.value);
  return cat?.name || null;
});

// Parse URL query params
const searchTerms = computed(() => {
  const q = route.query.q?.toString() || '';
  return q ? q.split(' ').filter(t => t.trim()) : [];
});

const selectedCategory = computed(() => {
  return route.query.category?.toString() || null;
});

const selectedCategoryName = computed(() => {
  if (!selectedCategory.value) return null;
  const category = directoryData.value.categories.find(c => c.id === selectedCategory.value);
  return category?.name || null;
});

const selectedTags = computed(() => {
  const tagsParam = route.query.tags?.toString() || '';
  if (!tagsParam) return [];
  const tagNames = tagsParam.split(',').filter(n => n.trim());
  return tagNames
    .map(name => {
      const tag = directoryData.value.tags.find(t => t.name === name);
      return tag ? { id: tag.id, name: tag.name || '' } : null;
    })
    .filter(t => t !== null) as { id: string; name: string }[];
});

// State checks
const hasTextSearch = computed(() => searchTerms.value.length > 0);
const hasCategoryOrTagFilter = computed(() => selectedCategory.value !== null || selectedTags.value.length > 0);
const hasTopLevelOnly = computed(() => selectedTopLevel.value !== null && !hasCategoryOrTagFilter.value);
const hasAnyInput = computed(() => hasTextSearch.value || hasCategoryOrTagFilter.value || selectedTopLevel.value !== null);

// Flatten categoryView into a single list of problems, sorted by name
const categoryViewProblems = computed(() => {
  const problems: ProblemWithTagNames[] = [];
  for (const category of directoryData.value.categoryView) {
    problems.push(...category.problems);
  }
  return problems.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
});

// Categories sorted by name
const sortedCategories = computed(() => {
  return [...directoryData.value.categories].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
});

// Available tags from framework.directory (sorted by information gain, top 5)
const availableTags = computed(() => {
  return directoryData.value.availableTags.slice(0, 5);
});

// Suggestions based on search input
const currentSearchInput = ref('');
const suggestions = computed(() => {
  if (currentSearchInput.value.length < 2) return [];

  const query = currentSearchInput.value.toLowerCase();
  const results: { id: string; name: string; type: 'tag' | 'job' }[] = [];
  const selectedIds = selectedTags.value.map(t => t.id);

  // Matching tags
  for (const tag of directoryData.value.tags) {
    if (tag.name?.toLowerCase().includes(query) && !selectedIds.includes(tag.id)) {
      results.push({ id: tag.id, name: tag.name || '', type: 'tag' });
    }
  }

  // Matching jobs
  for (const problem of directoryData.value.problems) {
    if (problem.name?.toLowerCase().includes(query)) {
      results.push({ id: problem.id, name: problem.name || '', type: 'job' });
    }
  }

  return results.slice(0, 10);
});

const selectTopLevel = (topLevel: { id: string; name: string }) => {
  router.push(`/directory?top=${topLevel.id}`);
};

const selectCategory = (category: ProblemCategoriesRecord) => {
  // Preserve top-level selection when selecting a sub-category
  if (selectedTopLevel.value) {
    router.push(`/directory?top=${selectedTopLevel.value}&category=${category.id}`);
  } else {
    router.push(`/directory?category=${category.id}`);
  }
};

const selectProblem = (problem: ProblemsRecord) => {
  router.push(`/job/${problem.id}`);
};

const selectProblemFromCategory = (problem: ProblemWithTagNames) => {
  router.push(`/job/${problem.id}`);
};

const handleNavigate = (url: string) => {
  router.push(url);
};


const handleSearchInput = (query: string) => {
  currentSearchInput.value = query;
};

// Breadcrumb navigation
const clickTopLevelBreadcrumb = () => {
  // Remove category and tags, keep only top-level
  router.push(`/directory?top=${selectedTopLevel.value}`);
};

const clickCategoryBreadcrumb = () => {
  // Remove all tags, keep top-level and category
  if (selectedTopLevel.value) {
    router.push(`/directory?top=${selectedTopLevel.value}&category=${selectedCategory.value}`);
  } else {
    router.push(`/directory?category=${selectedCategory.value}`);
  }
};

const clickTagBreadcrumb = (index: number) => {
  // Keep top-level, category, and tags up to and including the clicked index
  const tagsToKeep = selectedTags.value.slice(0, index + 1);
  const tagNames = tagsToKeep.map(t => t.name).join(',');

  let url = '/directory?';
  if (selectedTopLevel.value) {
    url += `top=${selectedTopLevel.value}&`;
  }
  if (selectedCategory.value) {
    url += `category=${selectedCategory.value}&`;
  }
  url += `tags=${tagNames}`;
  router.push(url);
};

const clearAll = () => {
  router.push('/directory');
};

// Reload directory data when URL changes
const reloadDirectory = async () => {
  loading.value = true;
  try {
    const text = searchTerms.value.join(' ') || null;
    const tagIds = selectedTags.value.map(t => t.id);

    directoryData.value = await loadDirectory({
      text,
      category: selectedCategory.value,
      tags: tagIds,
    });
  } finally {
    loading.value = false;
  }
};

watch(() => route.query, reloadDirectory, { deep: true });

onMounted(async () => {
  await tnfrStore.loadSpeedDial();
  await reloadDirectory();
});
</script>

<style scoped>
.directory-container {
  padding: 20px;
  padding-bottom: 500px;
  max-width: 1200px;
  margin: 0 auto;
}

.page-title {
  margin: 0 0 24px 0;
  color: var(--ion-text-color);
  font-size: 48px;
  font-weight: 700;
  text-align: center;
  font-variant: small-caps;
}

.loading-message,
.no-results-message {
  text-align: center;
  padding: 40px 20px;
  color: var(--ion-color-medium);
  font-size: 16px;
}

.categories-list {
  display: flex;
  flex-direction: column;
  gap: 0;
  max-width: 1000px;
  margin: 0 auto;
}

.search-results {
  display: flex;
  flex-direction: column;
  gap: 0;
  max-width: 1000px;
  margin: 0 auto;
}

.search-results > *:nth-child(odd) {
  background-color: var(--ion-color-light);
}

.category-item {
  cursor: pointer;
  padding: 16px;
  transition: background-color 0.1s ease;
}

.category-item:nth-child(odd) {
  background-color: var(--ion-color-light);
}

.category-item:hover {
  background-color: rgba(var(--ion-color-primary-rgb), 0.15);
}

.category-name {
  font-size: 18px;
  font-weight: 500;
  color: var(--ion-text-color);
  line-height: 1.3;
}

/* Top-level categories 2x2 grid */
.top-level-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
  max-width: 1000px;
  margin: 0 auto;
}

.top-level-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 32px 24px;
  background: var(--ion-color-light);
  border-radius: 12px;
  cursor: pointer;
  transition: all 0.2s ease;
  border: 2px solid transparent;
  min-height: 160px;
}

.top-level-card:hover:not(.top-level-card-disabled) {
  background: rgba(var(--ion-color-primary-rgb), 0.1);
  border-color: var(--ion-color-primary);
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.top-level-card-disabled {
  background: var(--ion-color-light-shade);
  cursor: default;
  opacity: 0.6;
}

.top-level-card-disabled .top-level-name {
  color: var(--ion-color-medium);
}

.top-level-card-disabled .top-level-button {
  background: var(--ion-color-medium);
  cursor: default;
}

.top-level-name {
  font-size: 22px;
  font-weight: 600;
  color: var(--ion-text-color);
  text-align: center;
}

/* Quick access chips */
.quick-access-section {
  max-width: 1000px;
  margin: 0 auto 24px;
}

.quick-access-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  justify-content: center;
}

.quick-access-chip {
  display: inline-block;
  padding: 8px 16px;
  background: var(--ion-color-primary);
  color: var(--ion-color-primary-contrast);
  border-radius: 20px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
}

.quick-access-chip:hover {
  opacity: 0.85;
  transform: translateY(-1px);
}

.top-level-button {
  padding: 10px 20px;
  background: var(--ion-color-success);
  color: var(--ion-color-success-contrast);
  border: none;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s ease;
}

.top-level-button:hover {
  opacity: 0.85;
}

.breadcrumb-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  max-width: 1000px;
  margin: 0 auto 16px;
  padding: 12px 16px;
  background: var(--ion-color-light);
  border-radius: 8px;
}

.breadcrumbs {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
}

.breadcrumb-item {
  display: inline-flex;
  align-items: center;
  position: relative;
  padding: 6px 12px;
  margin-left: 16px;
  background: var(--ion-color-primary);
  color: var(--ion-color-primary-contrast);
  border-radius: 16px;
  font-size: 14px;
  font-weight: 500;
}

.breadcrumb-item:first-child {
  margin-left: 0;
}

.breadcrumb-item + .breadcrumb-item::before {
  content: "›";
  position: absolute;
  left: -14px;
  color: var(--ion-color-medium);
  font-size: 16px;
  font-weight: bold;
}

.breadcrumb-item.clickable {
  cursor: pointer;
  transition: opacity 0.2s ease;
}

.breadcrumb-item.clickable:hover {
  opacity: 0.8;
}

.breadcrumb-item.search-crumb {
  background: var(--ion-color-medium);
  color: var(--ion-color-medium-contrast);
}

.clear-button {
  padding: 6px 16px;
  background: var(--ion-color-danger);
  color: var(--ion-color-danger-contrast);
  border: none;
  border-radius: 16px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: opacity 0.2s ease;
  flex-shrink: 0;
}

.clear-button:hover {
  opacity: 0.8;
}

/* Mobile adjustments */
@media (max-width: 767px) {
  .directory-container {
    padding: 16px;
  }

  .page-title {
    font-size: 36px;
  }

  .category-name {
    font-size: 18px;
  }

  .top-level-grid {
    gap: 12px;
    max-width: 100%;
  }

  .top-level-card {
    padding: 24px 16px;
    min-height: 140px;
  }

  .top-level-name {
    font-size: 18px;
  }

  .top-level-button {
    padding: 8px 16px;
    font-size: 13px;
  }
}
</style>