Hello from MCP server
<template>
<BaseLayout title="Technician">
<div class="edit-job-container">
<div v-if="loading" class="loading-message">Loading job data...</div>
<div v-else-if="!currentJob" class="no-job-message">Job not found</div>
<div v-else>
<h1 class="page-title">Edit Job</h1>
<!-- Job Name -->
<h2 class="job-name">
{{ editedJobName || currentJob?.problem?.name || "Untitled Job" }}
</h2>
<!-- Action Buttons -->
<div class="action-buttons">
<ion-button fill="solid" color="primary" @click="handleConfirmJob">
Confirm Job
</ion-button>
<ion-button fill="solid" color="danger" @click="handleCancel">
Cancel
</ion-button>
</div>
<!-- Data Table -->
<ion-grid v-if="menuData" class="data-table">
<!-- Header Row -->
<ion-row class="table-header">
<ion-col size="3">Field</ion-col>
<ion-col size="6">Value</ion-col>
<ion-col size="3">Default</ion-col>
</ion-row>
<!-- Job Name Row -->
<ion-row class="table-row editable-row" @click="focusJobNameInput">
<ion-col size="3">
<div class="cell-label">Job Name</div>
</ion-col>
<ion-col size="6">
<div class="cell-input">
<ion-icon :icon="pencilOutline" class="cell-edit-icon"></ion-icon>
<ion-input
ref="jobNameInputRef"
v-model="editedJobName"
placeholder="Enter job name"
class="inline-input"
></ion-input>
</div>
</ion-col>
<ion-col size="3">
<div class="cell-default">{{ currentJob?.problem?.name || '—' }}</div>
</ion-col>
</ion-row>
<!-- Menu Title Row -->
<ion-row class="table-row editable-row" @click="focusMenuTitleInput">
<ion-col size="3">
<div class="cell-label">Menu Title</div>
</ion-col>
<ion-col size="6">
<div class="cell-input">
<ion-icon :icon="pencilOutline" class="cell-edit-icon"></ion-icon>
<ion-input
ref="menuTitleInputRef"
v-model="editedMenuTitle"
placeholder="Enter menu title"
class="inline-input"
></ion-input>
</div>
</ion-col>
<ion-col size="3">
<div class="cell-default">{{ menuData.name }}</div>
</ion-col>
</ion-row>
<!-- Tiers -->
<template v-if="menuData.tiers && menuData.tiers.length > 0">
<template v-for="tier in menuData.tiers" :key="tier.id">
<!-- Tier Header Row -->
<ion-row class="table-row tier-row" :class="{ 'tier-removing': removingTiers.has(tier.id) }">
<ion-col size="3">
<div class="cell-label tier-label">{{ tier.name }}</div>
</ion-col>
<ion-col size="6">
<div class="cell-tier-info">
<span class="tier-title">{{ tier.tier?.name || tier.name }}</span>
<span class="tier-ref">{{ tier.offer?.refId || tier.refId }}</span>
</div>
</ion-col>
<ion-col size="3">
<ion-button
size="small"
color="danger"
fill="clear"
@click="removeTier(tier.id)"
:disabled="removingTiers.has(tier.id)"
>
<ion-icon :icon="trashOutline" slot="icon-only"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
<!-- Offer Title Row -->
<ion-row
class="table-row editable-row sub-row"
:class="{ 'tier-removing': removingTiers.has(tier.id) }"
>
<ion-col size="3">
<div class="cell-label">Offer Title</div>
</ion-col>
<ion-col size="6">
<div class="cell-input">
<ion-icon :icon="pencilOutline" class="cell-edit-icon"></ion-icon>
<ion-input
v-model="editedTiers[tier.id].offerTitle"
placeholder="Enter offer title"
class="inline-input"
></ion-input>
</div>
</ion-col>
<ion-col size="3">
<div class="cell-default">{{ getTierTitleItem(tier) || '—' }}</div>
</ion-col>
</ion-row>
<!-- Offer Details Row -->
<ion-row
class="table-row editable-row sub-row"
:class="{ 'tier-removing': removingTiers.has(tier.id) }"
@click="openDetailsModal(tier)"
>
<ion-col size="3">
<div class="cell-label">Details</div>
</ion-col>
<ion-col size="9">
<div class="cell-details">
<div class="cell-input">
<ion-icon :icon="pencilOutline" class="cell-edit-icon"></ion-icon>
<div class="details-preview">
<template v-if="getEditedContentItems(tier.id).length > 0">
<div
v-for="item in getEditedContentItems(tier.id).slice(0, 3)"
:key="item.id"
class="detail-item"
>
• {{ item.content }}
</div>
<div v-if="getEditedContentItems(tier.id).length > 3" class="detail-item more-items">
... and {{ getEditedContentItems(tier.id).length - 3 }} more
</div>
</template>
<div v-else class="no-content">No content items</div>
</div>
</div>
</div>
</ion-col>
</ion-row>
</template>
</template>
<ion-row v-else class="table-row">
<ion-col size="12">
<div class="no-content">No tiers available</div>
</ion-col>
</ion-row>
</ion-grid>
<div v-else-if="!loading" class="empty-state">
No menu data available
</div>
</div>
</div>
<!-- Details Edit Modal -->
<ion-modal :is-open="showDetailsModal" @didDismiss="closeDetailsModal">
<ion-header>
<ion-toolbar>
<ion-title>Edit Details</ion-title>
<ion-buttons slot="end">
<ion-button @click="closeDetailsModal">Done</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="details-modal-content">
<div class="modal-tier-name">{{ editingTier?.tier?.name || editingTier?.name }}</div>
<!-- In This Tier Section -->
<div class="bullets-section">
<div class="section-label">In This Tier</div>
<div v-if="tierBullets.length === 0" class="empty-section">
No bullets assigned to this tier
</div>
<div v-else class="content-items-list">
<div
v-for="item in tierBullets"
:key="item.id"
class="content-item-row"
>
<template v-if="editingBulletId === item.id">
<ion-input
v-model="editingBulletContent"
placeholder="Enter detail..."
class="content-item-input"
@keyup.enter="saveEditBullet"
@blur="saveEditBullet"
></ion-input>
<ion-button fill="clear" color="success" size="small" @click="saveEditBullet">
<ion-icon :icon="checkmarkOutline" slot="icon-only"></ion-icon>
</ion-button>
</template>
<template v-else>
<span class="bullet-content" @click="startEditBullet(item)">• {{ item.content }}</span>
<div class="bullet-actions">
<ion-button fill="clear" color="primary" size="small" @click="startEditBullet(item)" title="Edit">
<ion-icon :icon="createOutline" slot="icon-only"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" size="small" @click="handleRemoveFromTier(item.id)" title="Remove from tier">
<ion-icon :icon="removeCircleOutline" slot="icon-only"></ion-icon>
</ion-button>
<ion-button fill="clear" color="danger" size="small" @click="handleDeleteBullet(item.id)" title="Delete from all tiers">
<ion-icon :icon="trashOutline" slot="icon-only"></ion-icon>
</ion-button>
</div>
</template>
</div>
</div>
</div>
<!-- Available Bullets Section -->
<div v-if="availableBullets.length > 0" class="bullets-section">
<div class="section-label">Available (not in this tier)</div>
<div class="content-items-list available-list">
<div
v-for="item in availableBullets"
:key="item.id"
class="content-item-row available-item"
>
<span class="bullet-content">• {{ item.content }}</span>
<ion-button fill="clear" color="success" size="small" @click="handleAssignToTier(item.id)" title="Add to tier">
<ion-icon :icon="addCircleOutline" slot="icon-only"></ion-icon>
</ion-button>
</div>
</div>
</div>
<!-- Add New Bullet Section -->
<div class="bullets-section add-section">
<div class="section-label">Add New Bullet</div>
<div class="add-bullet-row">
<ion-input
v-model="newBulletContent"
placeholder="Enter new bullet point..."
class="content-item-input"
@keyup.enter="handleAddBullet"
></ion-input>
<ion-button
fill="solid"
color="primary"
size="small"
@click="handleAddBullet"
:disabled="!newBulletContent.trim()"
>
<ion-icon :icon="addOutline" slot="icon-only"></ion-icon>
</ion-button>
</div>
</div>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from "vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {
IonIcon,
IonButton,
IonInput,
IonGrid,
IonRow,
IonCol,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
toastController,
} from "@ionic/vue";
import {
trashOutline,
addOutline,
checkmarkOutline,
createOutline,
removeCircleOutline,
addCircleOutline,
} from "ionicons/icons";
import { mdPencil as pencilOutline } from "@/icons/customIcons";
import { useRoute, useRouter } from "vue-router";
import { useTnfrStore, type JobRecord } from "@/stores/tnfr";
const route = useRoute();
const router = useRouter();
const tnfrStore = useTnfrStore();
const currentJob = ref<JobRecord | null>(null);
const loading = ref(true);
const menuData = ref<any>(null);
// Edited values
const editedMenuTitle = ref("");
const editedJobName = ref("");
const editedTiers = reactive<
Record<string, { offerTitle: string; offerDetails: string }>
>({});
const jobNameInputRef = ref<InstanceType<typeof IonInput> | null>(null);
const menuTitleInputRef = ref<InstanceType<typeof IonInput> | null>(null);
// Details modal state
const showDetailsModal = ref(false);
const editingTier = ref<any>(null);
const newBulletContent = ref('');
const editingBulletId = ref<string | null>(null);
const editingBulletContent = ref('');
const focusJobNameInput = () => {
jobNameInputRef.value?.$el?.setFocus();
};
const focusMenuTitleInput = () => {
menuTitleInputRef.value?.$el?.setFocus();
};
const getTierTitleItem = (tier: any): string | null => {
const titleItem = tier.contentItems?.find((item: any) =>
item.refId?.includes("_title"),
);
return titleItem?.content || null;
};
const getContentItemsText = (tier: any): string => {
// Priority: use menuCopy if available (same as MenuTemplateDev)
if (tier.menuCopy && tier.menuCopy.length > 0) {
const allItems: string[] = [];
tier.menuCopy.forEach((copy: any) => {
if (copy.contentItems && copy.contentItems.length > 0) {
copy.contentItems.forEach((item: any) => {
allItems.push(item.content);
});
}
});
return allItems.join("\n");
}
// Fallback: use contentItems directly
if (!tier.contentItems || tier.contentItems.length === 0) {
return "";
}
// Filter out title items (same logic as menu template)
return tier.contentItems
.filter((item: any) => !item.refId?.includes("_title"))
.map((item: any) => item.content)
.join("\n");
};
// Get content items for a tier (from edited state or original)
const getContentItemsForTier = (tier: any): Array<{ id: string; content: string }> => {
const items: Array<{ id: string; content: string }> = [];
if (tier.menuCopy && tier.menuCopy.length > 0) {
tier.menuCopy.forEach((copy: any) => {
if (copy.contentItems && copy.contentItems.length > 0) {
copy.contentItems.forEach((item: any) => {
items.push({ id: item.id, content: item.content });
});
}
});
} else if (tier.contentItems && tier.contentItems.length > 0) {
tier.contentItems
.filter((item: any) => !item.refId?.includes("_title"))
.forEach((item: any) => {
items.push({ id: item.id, content: item.content });
});
}
return items;
};
// Get edited content items using tnfrStore's tier bullets
const getEditedContentItems = (tierId: string): Array<{ id: string; content: string }> => {
if (!currentJob.value) return [];
// Use tnfrStore's getTierBullets which reflects all edits
const bullets = tnfrStore.getTierBullets(currentJob.value.id, tierId);
if (bullets.length > 0) {
return bullets;
}
// Fall back to original tier content if no bullets in store yet
const tier = menuData.value?.tiers?.find((t: any) => t.id === tierId);
if (tier) {
return getContentItemsForTier(tier);
}
return [];
};
// Computed: bullets in this tier
const tierBullets = computed(() => {
if (!currentJob.value || !editingTier.value) return [];
return tnfrStore.getTierBullets(currentJob.value.id, editingTier.value.id);
});
// Computed: bullets available but not in this tier
const availableBullets = computed(() => {
if (!currentJob.value || !editingTier.value) return [];
const allBullets = tnfrStore.getMenuBullets(currentJob.value.id);
const tierBulletIds = new Set(tierBullets.value.map(b => b.id));
return allBullets.filter(b => !tierBulletIds.has(b.id));
});
// Open details modal for a tier
const openDetailsModal = (tier: any) => {
editingTier.value = tier;
newBulletContent.value = '';
editingBulletId.value = null;
editingBulletContent.value = '';
showDetailsModal.value = true;
};
// Close details modal
const closeDetailsModal = () => {
showDetailsModal.value = false;
editingTier.value = null;
editingBulletId.value = null;
};
// Start editing a bullet
const startEditBullet = (item: { id: string; content: string }) => {
editingBulletId.value = item.id;
editingBulletContent.value = item.content;
};
// Save edited bullet
const saveEditBullet = async () => {
if (!currentJob.value || !editingBulletId.value) return;
if (editingBulletContent.value.trim()) {
await tnfrStore.editMenuBullet(currentJob.value.id, editingBulletId.value, editingBulletContent.value.trim());
const toast = await toastController.create({
message: 'Bullet updated',
duration: 1500,
color: 'success',
position: 'bottom',
});
await toast.present();
}
editingBulletId.value = null;
editingBulletContent.value = '';
};
// Add new bullet
const handleAddBullet = async () => {
if (!currentJob.value || !newBulletContent.value.trim()) return;
await tnfrStore.addMenuBullet(currentJob.value.id, newBulletContent.value.trim());
newBulletContent.value = '';
const toast = await toastController.create({
message: 'Bullet added to all tiers',
duration: 1500,
color: 'success',
position: 'bottom',
});
await toast.present();
};
// Remove bullet from this tier only
const handleRemoveFromTier = async (bulletId: string) => {
if (!currentJob.value || !editingTier.value) return;
await tnfrStore.removeBulletFromTier(currentJob.value.id, editingTier.value.id, bulletId);
const toast = await toastController.create({
message: 'Removed from tier',
duration: 1500,
color: 'warning',
position: 'bottom',
});
await toast.present();
};
// Assign bullet to this tier
const handleAssignToTier = async (bulletId: string) => {
if (!currentJob.value || !editingTier.value) return;
await tnfrStore.assignMenuBulletToTier(currentJob.value.id, editingTier.value.id, bulletId);
const toast = await toastController.create({
message: 'Added to tier',
duration: 1500,
color: 'success',
position: 'bottom',
});
await toast.present();
};
// Delete bullet from all tiers
const handleDeleteBullet = async (bulletId: string) => {
if (!currentJob.value) return;
await tnfrStore.removeMenuBullet(currentJob.value.id, bulletId);
const toast = await toastController.create({
message: 'Deleted from all tiers',
duration: 1500,
color: 'danger',
position: 'bottom',
});
await toast.present();
};
// Track tiers being removed for animation
const removingTiers = ref<Set<string>>(new Set());
const removeTier = async (tierId: string) => {
if (!menuData.value?.tiers) return;
const tier = menuData.value.tiers.find((t: any) => t.id === tierId);
const tierName = tier?.name || 'Tier';
// Add to removing set to trigger animation
removingTiers.value.add(tierId);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 300));
// Remove the tier from menuData
const tierIndex = menuData.value.tiers.findIndex((t: any) => t.id === tierId);
if (tierIndex !== -1) {
menuData.value.tiers.splice(tierIndex, 1);
}
// Remove from editedTiers and removingTiers
delete editedTiers[tierId];
removingTiers.value.delete(tierId);
// Show toast
const toast = await toastController.create({
message: `${tierName} removed`,
duration: 1500,
color: 'warning',
position: 'bottom',
});
await toast.present();
};
const handleConfirmJob = async () => {
if (!currentJob.value) return;
// Update job name directly on the job object (before confirming)
if (editedJobName.value.trim()) {
currentJob.value.title = editedJobName.value.trim();
}
// Update menu title if edited
if (editedMenuTitle.value.trim() && menuData.value) {
menuData.value.name = editedMenuTitle.value.trim();
}
// Update offer titles in tier contentItems
if (menuData.value?.tiers) {
for (const tier of menuData.value.tiers) {
const editedTitle = editedTiers[tier.id]?.offerTitle;
if (editedTitle && tier.contentItems) {
const titleItem = tier.contentItems.find((item: any) => item.refId?.includes('_title'));
if (titleItem) {
titleItem.content = editedTitle;
titleItem.name = editedTitle;
}
}
}
}
// Update the job's menu data with removed tiers
if (currentJob.value.menus && currentJob.value.menus.length > 0) {
currentJob.value.menus[0] = menuData.value;
}
// Mark job as edited
currentJob.value.edited = true;
// Confirm the pending job (moves to jobs array)
await tnfrStore.confirmPendingJob();
// Show success toast
const toast = await toastController.create({
message: "Job confirmed",
duration: 1500,
color: "success",
position: "bottom",
});
await toast.present();
// Navigate to cart
router.push('/cart');
};
const handleCancel = async () => {
await tnfrStore.clearPendingJob();
router.push('/directory');
};
onMounted(async () => {
loading.value = true;
await tnfrStore.load();
const jobId = route.params.jobId as string;
if (jobId) {
// Check pendingJob first, then jobs array
const job = tnfrStore.pendingJob?.id === jobId
? tnfrStore.pendingJob
: tnfrStore.jobs.find((j) => j.id === jobId);
if (job) {
currentJob.value = job as any;
// Initialize job name from existing title or problem name
editedJobName.value = job.title || job.problem?.name || "";
// Use menu data from the job's menus array (already loaded in tnfrStore)
if (job.menus && job.menus.length > 0) {
const menu = job.menus[0];
menuData.value = menu;
// Initialize edited values
const existingMods = (job as any).menuModifications;
editedMenuTitle.value = existingMods?.menuTitle || menu.name || "";
// Initialize tier editing state
menu.tiers?.forEach((tier: any) => {
const tierMod = existingMods?.tierModifications?.[tier.id];
// Get tier title from content items with _title refId
const titleItem = tier.contentItems?.find((item: any) =>
item.refId?.includes("_title"),
);
const defaultTitle = titleItem?.content || tier.offer?.name || "";
editedTiers[tier.id] = {
offerTitle: tierMod?.offerTitle || defaultTitle,
offerDetails:
tierMod?.offerDetails || getContentItemsText(tier) || "",
};
});
}
}
}
loading.value = false;
});
</script>
<style scoped>
.edit-job-container {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.page-title {
margin: 0 0 8px 0;
color: var(--ion-color-medium);
font-size: 14px;
font-weight: 500;
text-align: center;
text-transform: uppercase;
letter-spacing: 1px;
}
.job-name {
margin: 0 0 24px 0;
color: var(--ion-text-color);
font-size: 24px;
font-weight: 600;
text-align: center;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-bottom: 24px;
}
.loading-message,
.no-job-message,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--ion-color-medium);
font-size: 14px;
}
/* Data Table - PriceList style */
.data-table {
background: var(--ion-color-light);
border-radius: 8px;
overflow: hidden;
margin-bottom: 24px;
}
.table-header {
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table-header ion-col {
padding: 12px 16px;
}
.table-row {
border-bottom: 1px solid var(--ion-color-light-shade);
background: var(--ion-background-color);
}
.table-row:last-child {
border-bottom: none;
}
.table-row ion-col {
padding: 12px 16px;
display: flex;
align-items: center;
}
.editable-row {
cursor: pointer;
transition: background-color 0.2s;
}
.editable-row:hover {
background: var(--ion-color-light);
}
.tier-row {
background: var(--ion-color-light);
border-top: 2px solid var(--ion-color-medium);
transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease;
}
.tier-removing {
opacity: 0;
transform: translateX(-20px);
background-color: var(--ion-color-danger-tint) !important;
}
.sub-row {
background: var(--ion-background-color);
transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease;
}
.cell-label {
font-size: 14px;
font-weight: 500;
color: var(--ion-color-medium);
}
.tier-label {
font-weight: 600;
color: var(--ion-color-dark);
font-size: 16px;
}
.cell-input {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.cell-edit-icon {
font-size: 16px;
color: var(--ion-color-primary);
flex-shrink: 0;
}
.cell-value {
font-size: 14px;
color: var(--ion-color-dark);
}
.inline-input {
--padding-start: 0;
--padding-end: 0;
font-size: 14px;
}
.cell-default {
font-size: 13px;
color: var(--ion-color-medium);
font-style: italic;
}
.cell-tier-info {
display: flex;
align-items: center;
gap: 12px;
}
.tier-title {
font-size: 14px;
font-weight: 600;
color: var(--ion-color-dark);
text-transform: capitalize;
}
.tier-ref {
font-size: 12px;
color: var(--ion-color-medium);
font-family: monospace;
}
.cell-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-item {
font-size: 13px;
color: var(--ion-color-dark);
line-height: 1.4;
}
.no-content {
font-size: 13px;
color: var(--ion-color-medium);
font-style: italic;
}
.details-preview {
flex: 1;
}
.more-items {
color: var(--ion-color-medium);
font-style: italic;
}
/* Details Modal Styles */
.details-modal-content {
padding: 16px;
}
.modal-tier-name {
font-size: 20px;
font-weight: 700;
color: var(--ion-text-color);
text-transform: capitalize;
margin-bottom: 20px;
text-align: center;
}
.content-items-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.content-item-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--ion-color-light);
border-radius: 8px;
padding: 4px 8px 4px 16px;
}
.content-item-input {
flex: 1;
--padding-start: 0;
--padding-end: 0;
font-size: 15px;
}
/* Bullets Section Styles */
.bullets-section {
margin-bottom: 24px;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: var(--ion-color-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.empty-section {
font-size: 14px;
color: var(--ion-color-medium);
font-style: italic;
padding: 12px;
text-align: center;
background: var(--ion-color-light);
border-radius: 8px;
}
.bullet-content {
flex: 1;
font-size: 15px;
color: var(--ion-text-color);
cursor: pointer;
padding: 8px 0;
}
.bullet-content:hover {
color: var(--ion-color-primary);
}
.bullet-actions {
display: flex;
gap: 0;
}
.available-list .content-item-row {
background: var(--ion-color-light-tint);
}
.available-item {
opacity: 0.8;
}
.add-section {
border-top: 1px solid var(--ion-color-light-shade);
padding-top: 20px;
}
.add-bullet-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--ion-color-light);
border-radius: 8px;
padding: 4px 8px 4px 16px;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.edit-job-container {
padding: 16px;
}
.table-header {
display: none;
}
.table-row ion-col {
padding: 8px 12px;
}
.action-buttons {
flex-direction: column;
}
}
</style>