Hello from MCP server
<template>
<BaseLayout title="Technician" @debug="isDebugOpen = true">
<div class="job-content-container">
<h1 class="page-title">{{ jobTitle }}</h1>
<p v-if="jobRefId" class="page-subtitle">{{ jobRefId }}</p>
<div v-if="isLoading" class="loading-message">
Loading tech handbook...
</div>
<div v-else-if="!currentJob && !jobContentHierarchy" class="error-message">
Job not found
</div>
<div v-else>
<!-- Action Buttons -->
<div class="action-buttons">
<template v-if="tnfrStore.invoice.paymentConfirmed">
<ion-button fill="solid" color="primary" @click="router.push('/review')">
Go to Review
</ion-button>
</template>
<template v-else>
<ion-button fill="solid" color="success" @click="addToCart">
Select this Menu
</ion-button>
<ion-button fill="solid" color="danger" @click="router.push('/directory')">
Select Another Menu
</ion-button>
</template>
</div>
<!-- Accordion Sections -->
<ion-accordion-group :multiple="true">
<!-- Job Description -->
<ion-accordion value="job-description">
<ion-item slot="header" class="accordion-header">
<ion-label>Job Description</ion-label>
</ion-item>
<div slot="content" class="accordion-content">
<div v-if="jobContentHierarchy?.problem?.description">
<p class="job-description-text">{{ jobContentHierarchy.problem.description }}</p>
<p v-if="jobRefId" class="job-ref-id">{{ jobRefId }}</p>
</div>
<p v-else class="no-content">No job description available.</p>
</div>
</ion-accordion>
<!-- Tech Handbook -->
<ion-accordion value="tech-handbook">
<ion-item slot="header" class="accordion-header">
<ion-label>Tech Handbook</ion-label>
</ion-item>
<div slot="content" class="accordion-content">
<div v-if="displayedTechHandbook.length > 0">
<div v-for="tier in displayedTechHandbook" :key="tier.tierName" class="tier-section">
<h4 class="tier-name">{{ tier.tierName }} Tech Handbook</h4>
<ol class="handbook-list">
<li v-for="(item, index) in tier.items" :key="index">{{ item }}</li>
</ol>
</div>
</div>
<p v-else class="no-content">No tech handbook information available.</p>
</div>
</ion-accordion>
<!-- Menu Preview -->
<ion-accordion value="menu-preview">
<ion-item slot="header" class="accordion-header">
<ion-label>Menu Preview</ion-label>
</ion-item>
<div slot="content" class="accordion-content">
<div v-if="displayedMenuContent.length > 0">
<div v-for="tier in displayedMenuContent" :key="tier.tierName" class="tier-section">
<h4 class="tier-name">{{ tier.tierName }}</h4>
<h5 v-if="tier.items.length > 0" class="menu-item-title">{{ tier.items[0] }}</h5>
<ul v-if="tier.items.length > 1" class="menu-content-list">
<li v-for="(item, index) in tier.items.slice(1)" :key="index">{{ item }}</li>
</ul>
</div>
</div>
<p v-else class="no-content">No menu content available.</p>
</div>
</ion-accordion>
<!-- Costs (Author/Admin only) -->
<ion-accordion v-if="showCosts" value="costs">
<ion-item slot="header" class="accordion-header">
<ion-label>Costs</ion-label>
</ion-item>
<div slot="content" class="accordion-content">
<div v-if="displayedCosts.length > 0">
<div v-for="tier in displayedCosts" :key="tier.tierName" class="tier-section">
<h4 class="tier-name">{{ tier.tierName }}</h4>
<!-- Time Costs -->
<div v-if="tier.costsTime.length > 0" class="costs-subsection">
<h5 class="costs-subtitle">Time</h5>
<ul class="costs-list">
<li v-for="cost in tier.costsTime" :key="cost.id">
{{ cost.name }}: {{ cost.hours }} hr{{ cost.hours !== 1 ? 's' : '' }}
</li>
</ul>
</div>
<!-- Material Costs -->
<div v-if="tier.costsMaterial.length > 0" class="costs-subsection">
<h5 class="costs-subtitle">Materials</h5>
<ul class="costs-list">
<li v-for="cost in tier.costsMaterial" :key="cost.id">
{{ cost.name }}: <ShowCurrency :currency-in="cost.quantity" />
</li>
</ul>
</div>
</div>
</div>
<p v-else class="no-content">No cost information available.</p>
</div>
</ion-accordion>
</ion-accordion-group>
</div>
</div>
<!-- Debug Console -->
<DebugConsole :is-open="isDebugOpen" @close="isDebugOpen = false">
<div class="debug-section">
<h3 class="debug-section-title">Job Info</h3>
<table class="debug-table">
<tr>
<td>Job ID:</td>
<td>{{ currentJob?.id || 'N/A' }}</td>
</tr>
<tr>
<td>Title:</td>
<td>{{ currentJob?.title || 'N/A' }}</td>
</tr>
<tr>
<td>Problem ID:</td>
<td>{{ currentJob?.problem?.id || 'N/A' }}</td>
</tr>
<tr>
<td>Problem Name:</td>
<td>{{ currentJob?.problem?.name || 'N/A' }}</td>
</tr>
<tr>
<td>Base Hours:</td>
<td>{{ currentJob?.baseHours || 0 }}</td>
</tr>
<tr>
<td>Extra Time:</td>
<td>{{ currentJob?.extraTime || 0 }}</td>
</tr>
<tr>
<td>Selected Tier:</td>
<td>{{ currentJob?.selectedTierName || 'None' }}</td>
</tr>
<tr>
<td>Selected Price:</td>
<td>{{ currentJob?.selectedPrice || 'N/A' }}</td>
</tr>
</table>
</div>
<div class="debug-section">
<h3 class="debug-section-title">Menu Data</h3>
<table class="debug-table">
<tr>
<td>Has Menu Data:</td>
<td :class="currentJob?.menuData ? 'debug-success' : 'debug-error'">
{{ currentJob?.menuData ? 'Yes' : 'No' }}
</td>
</tr>
<tr>
<td>Tiers Count:</td>
<td>{{ currentJob?.menuData?.tiers?.length || 0 }}</td>
</tr>
</table>
</div>
<div class="debug-section">
<h3 class="debug-section-title">Tech Handbook (from hierarchy)</h3>
<table class="debug-table">
<tr>
<td>Tiers with Handbook:</td>
<td>{{ displayedTechHandbook.length }}</td>
</tr>
<tr v-for="tier in displayedTechHandbook" :key="tier.tierName">
<td>{{ tier.tierName }}:</td>
<td>{{ tier.items.length }} items</td>
</tr>
</table>
</div>
<div class="debug-section">
<h3 class="debug-section-title">Raw Job Data</h3>
<pre class="debug-json">{{ JSON.stringify(currentJob, null, 2) }}</pre>
</div>
<div class="debug-section">
<h3 class="debug-section-title">Job Content Hierarchy (loadJobContentByProblemId)</h3>
<pre class="debug-json">{{ JSON.stringify(jobContentHierarchy, null, 2) }}</pre>
</div>
</DebugConsole>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { IonButton, IonAccordionGroup, IonAccordion, IonItem, IonLabel, onIonViewWillEnter } from '@ionic/vue';
import BaseLayout from '@/components/BaseLayout.vue';
import DebugConsole from '@/components/DebugConsole.vue';
import ShowCurrency from '@/components/ShowCurrency.vue';
import { useTnfrStore } from '@/stores/tnfr';
import { loadJobContentByProblemId, type JobContentHierarchy } from '@/framework/jobContent';
const route = useRoute();
const router = useRouter();
const tnfrStore = useTnfrStore();
const isLoading = ref(true);
const isDebugOpen = ref(false);
const jobContentHierarchy = ref<JobContentHierarchy | null>(null);
// Get problem ID from route
const problemId = computed(() => route.params.problemId as string);
// Get all jobs in cart with this problem ID
const jobsInCart = computed(() => {
if (!tnfrStore.jobs || !problemId.value) return [];
return tnfrStore.jobs.filter(j => j.problem?.id === problemId.value);
});
// Get the current job from session store (find by problem ID) - first one
const currentJob = computed(() => {
return jobsInCart.value.length > 0 ? jobsInCart.value[0] : null;
});
// Job title - use currentJob if available, otherwise use jobContentHierarchy
const jobTitle = computed(() => {
if (currentJob.value) {
return currentJob.value.title || currentJob.value.problem?.name || 'Untitled Job';
}
return jobContentHierarchy.value?.problem?.name || 'Untitled Job';
});
// Job ref ID - use currentJob if available, otherwise use jobContentHierarchy
// Remove "_problem" suffix from refId
const jobRefId = computed(() => {
let refId = '';
if (currentJob.value) {
refId = currentJob.value.problem?.refId || '';
} else {
refId = jobContentHierarchy.value?.problem?.refId || '';
}
return refId.replace(/_problem$/, '');
});
// Build menu content data from jobContentHierarchy
const displayedMenuContent = computed(() => {
const hierarchy = jobContentHierarchy.value;
if (!hierarchy?.menus?.length) return [];
const allTiers: { tierName: string; offerId: string; items: string[] }[] = [];
for (const menu of hierarchy.menus) {
for (const menuTier of menu.tiers) {
// Collect content items: direct contentItems (title) first, then menuCopy (details)
const items: string[] = [];
// Add direct content items first (menu item title)
for (const ci of menuTier.contentItems || []) {
if (ci.content) {
items.push(ci.content);
}
}
// Add menuCopy content items (details)
for (const mc of menuTier.menuCopy || []) {
for (const ci of mc.contentItems || []) {
if (ci.content) {
items.push(ci.content);
}
}
}
if (items.length > 0) {
allTiers.push({
tierName: menuTier.tier.name,
offerId: menuTier.offer.id,
items,
});
}
}
}
return allTiers;
});
// Build tech handbook data from jobContentHierarchy
const displayedTechHandbook = computed(() => {
const hierarchy = jobContentHierarchy.value;
if (!hierarchy?.menus?.length) return [];
const allTiers: { tierName: string; offerId: string; items: string[] }[] = [];
for (const menu of hierarchy.menus) {
for (const menuTier of menu.tiers) {
const items = menuTier.offer.techHandbook
.map(item => item.content)
.filter(content => content)
.reverse();
if (items.length > 0) {
allTiers.push({
tierName: menuTier.tier.name,
offerId: menuTier.offer.id,
items,
});
}
}
}
return allTiers;
});
// Check if user has author or admin role AND secret mode is enabled
const showCosts = computed(() => {
const hasRole = tnfrStore.role === 'author' || tnfrStore.role === 'admin';
return hasRole && tnfrStore.secretMode;
});
// Build costs data from jobContentHierarchy
const displayedCosts = computed(() => {
const hierarchy = jobContentHierarchy.value;
if (!hierarchy?.menus?.length) return [];
const allTiers: { tierName: string; offerId: string; costsTime: any[]; costsMaterial: any[] }[] = [];
for (const menu of hierarchy.menus) {
for (const menuTier of menu.tiers) {
const costsTime = menuTier.offer.costsTime || [];
const costsMaterial = menuTier.offer.costsMaterial || [];
if (costsTime.length > 0 || costsMaterial.length > 0) {
allTiers.push({
tierName: menuTier.tier.name,
offerId: menuTier.offer.id,
costsTime,
costsMaterial,
});
}
}
}
return allTiers;
});
const addToCart = async () => {
// Add the problem to the cart and navigate to confirm hours
const problem = jobContentHierarchy.value?.problem;
if (problem?.id) {
await tnfrStore.addJob(problem.id);
router.push(`/check-hours/${problem.id}`);
}
};
const loadContent = async () => {
isLoading.value = true;
try {
await tnfrStore.load();
// Load the full job content hierarchy using problem ID from route
if (problemId.value) {
console.log('[JobContent] Loading content for problem ID:', problemId.value);
jobContentHierarchy.value = await loadJobContentByProblemId(problemId.value);
console.log('[JobContent] Loaded hierarchy:', jobContentHierarchy.value);
}
} catch (error) {
console.error('[JobContent] Error loading:', error);
} finally {
isLoading.value = false;
}
};
onMounted(loadContent);
// Ionic lifecycle hook - fires every time view becomes active (including back navigation)
onIonViewWillEnter(loadContent);
</script>
<style scoped>
.job-content-container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.page-title {
margin: 0 0 24px 0;
color: var(--ion-text-color);
font-size: 32px;
font-weight: 700;
text-align: center;
font-variant: small-caps;
}
.page-title + .page-subtitle {
margin-top: -20px;
}
.page-subtitle {
margin: 0 0 24px 0;
color: var(--ion-color-medium);
font-size: 14px;
text-align: center;
font-family: monospace;
}
.job-ref-id {
margin: 16px 0 0 0;
color: var(--ion-color-medium);
font-size: 14px;
text-align: right;
font-family: monospace;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.job-description-text {
color: var(--ion-text-color);
line-height: 1.6;
font-size: 16px;
margin: 0;
text-align: left;
white-space: pre-line;
}
.accordion-header {
--background: var(--ion-card-background, var(--ion-background-color));
font-size: 18px;
font-weight: 600;
}
.accordion-content {
padding: 16px 24px 24px 24px;
background: var(--ion-card-background, var(--ion-background-color));
}
ion-accordion-group {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.loading-message,
.error-message,
.no-content {
text-align: center;
color: var(--ion-color-medium);
font-style: italic;
padding: 40px 20px;
}
.error-message {
color: var(--ion-color-danger);
}
.tier-section {
margin-bottom: 24px;
}
.tier-section:last-child {
margin-bottom: 0;
}
.tier-name {
margin: 0 0 12px 0;
color: var(--ion-color-primary);
font-size: 18px;
font-weight: 600;
}
.handbook-list {
margin: 0;
padding-left: 24px;
color: var(--ion-text-color);
}
.handbook-list li {
margin-bottom: 8px;
line-height: 1.5;
font-size: 16px;
}
.costs-subsection {
margin-bottom: 16px;
}
.costs-subsection:last-child {
margin-bottom: 0;
}
.costs-subtitle {
margin: 0 0 8px 0;
color: var(--ion-color-medium);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.costs-list {
margin: 0;
padding-left: 24px;
color: var(--ion-text-color);
}
.costs-list li {
margin-bottom: 6px;
line-height: 1.4;
font-size: 15px;
}
.menu-item-title {
margin: 0 0 12px 0;
color: var(--ion-text-color);
font-size: 17px;
font-weight: 600;
}
.menu-content-list {
margin: 0;
padding-left: 24px;
color: var(--ion-text-color);
}
.menu-content-list li {
margin-bottom: 8px;
line-height: 1.5;
font-size: 16px;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.job-content-container {
padding: 16px;
}
.page-title {
font-size: 28px;
}
.page-subtitle {
font-size: 12px;
}
.job-name {
font-size: 20px;
}
.accordion-content {
padding: 12px 16px 16px 16px;
}
.tier-name {
font-size: 16px;
}
.handbook-list li {
font-size: 14px;
}
}
</style>