Hello from MCP server
<template>
<BaseLayout title="Customer">
<!-- Debug Toolbar -->
<DebugToolbar
ref="debugToolbar"
:function-groups="functionGroups"
:reactive-data="reactiveVarsData"
>
<template #info-sections>
<div class="debug-section">
<strong>Jobs Count:</strong> {{ tnfrStore.jobs.length }}
</div>
<div class="debug-section">
<strong>Total Price:</strong> {{ totalPrice }}
</div>
<div class="debug-section">
<strong>Has Selected Offers:</strong> {{ hasAnySelectedOffers }}
</div>
<div class="debug-section">
<strong>Jobs Needing Hours:</strong> {{ jobsNeedingHoursConfirmation }}
</div>
</template>
</DebugToolbar>
<div class="cart-container">
<div class="content-section">
<div v-if="tnfrStore.jobs.length === 0" class="empty-state">
<p class="empty-text">No jobs added yet</p>
<ion-button color="primary" router-link="/directory/">
<ion-icon slot="start" :icon="addOutline" />
Add Job
</ion-button>
</div>
<div v-else class="jobs-list">
<!-- Cart Grid -->
<div class="cart-grid">
<div
v-for="job in sortedJobs"
:key="job.id"
class="cart-tile"
:class="getTileClass(job)"
@click="showMenuAndClear(job)"
>
<ion-button
fill="clear"
color="danger"
class="tile-remove-icon"
@click.stop="removeFromCart(job)"
>
<ion-icon slot="icon-only" :icon="trashOutline" />
</ion-button>
<span class="cart-tile-name">{{ getMenuTitle(job) }}</span>
<ion-chip :class="getChipClass(job)">
{{ getRefId(job) }}
</ion-chip>
<button
class="cart-tile-button"
@click.stop="showMenuAndClear(job)"
>
Show Menu
</button>
</div>
<!-- Add Job Tile -->
<div class="cart-tile cart-tile-add" @click="router.push('/directory/')">
<ion-icon :icon="addOutline" class="cart-tile-add-icon" />
<span class="cart-tile-name">Add Job</span>
</div>
</div>
<!-- Total Section -->
<div class="total-section">
<div class="total-label">Total:</div>
<div class="total-price">
<show-currency :currencyIn="totalPrice" />
</div>
</div>
<!-- Checkout / Show Menus Button -->
<ion-button
v-if="allJobsHaveSelectedOffers"
expand="block"
color="success"
class="checkout-button"
@click="handleCheckout"
>
CHECKOUT
</ion-button>
<ion-button
v-else
expand="block"
color="success"
class="checkout-button"
@click="handleShowMenus"
>
Show Menus
</ion-button>
</div>
</div>
</div>
</BaseLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeMount, onBeforeUnmount, onUnmounted, onActivated, onDeactivated, ref } from "vue";
import { useRouter } from "vue-router";
import BaseLayout from "@/components/BaseLayout.vue";
import { IonButton, IonIcon, IonChip, onIonViewWillEnter } from "@ionic/vue";
import { addOutline, trashOutline } from "ionicons/icons";
import { useTnfrStore, type JobRecord } from "@/stores/tnfr";
import { useSessionStore } from "@/stores/session";
import ShowCurrency from "@/components/ShowCurrency.vue";
import DebugToolbar, { type FunctionGroup } from "@/components/DebugToolbar.vue";
const router = useRouter();
const tnfrStore = useTnfrStore();
const sessionStore = useSessionStore();
const debugToolbar = ref<InstanceType<typeof DebugToolbar> | null>(null);
// Debug function groups
const functionGroups = ref<FunctionGroup[]>([
{
label: 'View Functions',
functions: [
{ label: 'goToDashboard()', value: 'goToDashboard' },
{ label: 'handleJobClick(job)', value: 'handleJobClick' },
{ label: 'handleShowMenus()', value: 'handleShowMenus' },
{ label: 'handleCheckout()', value: 'handleCheckout' },
{ label: 'getMenuTitle(job)', value: 'getMenuTitle' },
{ label: 'getJobPrice(job)', value: 'getJobPrice' },
{ label: 'hasSelectedOffer(job)', value: 'hasSelectedOffer' },
{ label: 'getOfferTierName(job)', value: 'getOfferTierName' },
{ label: 'getSelectedTier(job)', value: 'getSelectedTier' },
{ label: 'getTierContentItems(job)', value: 'getTierContentItems' },
],
},
{
label: '@/stores/tnfr',
functions: [
{ label: 'tnfrStore.load()', value: 'tnfrStore.load' },
{ label: 'tnfrStore.save()', value: 'tnfrStore.save' },
{ label: 'tnfrStore.clear()', value: 'tnfrStore.clear' },
{ label: 'tnfrStore.addJob(problemId)', value: 'tnfrStore.addJob' },
{ label: 'tnfrStore.removeJob(jobId)', value: 'tnfrStore.removeJob' },
],
},
{
label: '@/stores/session',
functions: [
{ label: 'sessionStore.load()', value: 'sessionStore.load' },
],
},
{
label: 'vue-router',
functions: [
{ label: 'router.push(path)', value: 'router.push' },
],
},
]);
// Helper to log executions via the toolbar
function logExecution(name: string, args?: any[]) {
debugToolbar.value?.logExecution(name, args);
}
// Track lifecycle
onBeforeMount(() => debugToolbar.value?.logLifecycle('onBeforeMount'));
onBeforeUnmount(() => debugToolbar.value?.logLifecycle('onBeforeUnmount'));
onUnmounted(() => debugToolbar.value?.logLifecycle('onUnmounted'));
onActivated(() => debugToolbar.value?.logLifecycle('onActivated'));
onDeactivated(() => debugToolbar.value?.logLifecycle('onDeactivated'));
// Computed: All reactive variables for debug display
const reactiveVarsData = computed(() => ({
// Store state
'tnfrStore.jobs': tnfrStore.jobs,
// Computed
sortedJobs: sortedJobs.value,
hasAnySelectedOffers: hasAnySelectedOffers.value,
allJobsHaveSelectedOffers: allJobsHaveSelectedOffers.value,
jobsNeedingHoursConfirmation: jobsNeedingHoursConfirmation.value,
canShowMenus: canShowMenus.value,
canCheckout: canCheckout.value,
canStartWork: canStartWork.value,
nextStepMessage: nextStepMessage.value,
totalPrice: totalPrice.value,
}));
const goToDashboard = () => {
logExecution('goToDashboard');
router.push("/service-call");
};
const handleJobClick = (job: JobRecord) => {
logExecution('handleJobClick', [job.id]);
// Navigate to legacy menu template for this job
router.push(`/job/${job.id}/show/legacy`);
};
const handleShowMenus = () => {
logExecution('handleShowMenus');
// Navigate to the first job's menu
if (tnfrStore.jobs.length > 0) {
const firstJob = tnfrStore.jobs[0];
router.push(`/menu/${firstJob.id}`);
}
};
const getMenuTitle = (job: JobRecord): string => {
return job.title || job.problem?.name || 'Untitled';
};
const getStatusMessage = (job: JobRecord): string => {
if (hasSelectedOffer(job)) {
return 'Ready';
}
if (!job.hoursConfirmed) {
return 'Needs hours confirmed';
}
return 'Waiting for customer selection';
};
const getStatusClass = (job: JobRecord): string => {
if (hasSelectedOffer(job)) {
return 'status-ready';
}
if (!job.hoursConfirmed) {
return 'status-needs-hours';
}
return 'status-waiting';
};
const getJobPrice = (job: JobRecord): number => {
// Only return a price if the job has a selected offer
if (!hasSelectedOffer(job)) {
return 0;
}
if (job.selectedPrice) {
const price = parseFloat(job.selectedPrice);
return isNaN(price) ? 0 : price;
}
return 0;
};
const hasSelectedOffer = (job: JobRecord): boolean => {
// Check if job has a selected tier name (which means an offer was confirmed)
return !!(job.selectedTierName && job.selectedPrice);
};
const getOfferTierName = (job: JobRecord): string => {
return job.selectedTierName || '';
};
const getBannerClass = (job: JobRecord): string => {
if (!hasSelectedOffer(job)) return 'no-selection';
const tierName = (job.selectedTierName || '').toLowerCase();
if (tierName.includes('platinum')) return 'tier-platinum';
if (tierName.includes('gold')) return 'tier-gold';
if (tierName.includes('silver')) return 'tier-silver';
if (tierName.includes('bronze')) return 'tier-bronze';
if (tierName.includes('band')) return 'tier-bandaid';
return 'tier-default';
};
const getTileClass = (job: JobRecord): string => {
if (!hasSelectedOffer(job)) return 'tile-no-selection';
const tierName = (job.selectedTierName || '').toLowerCase();
if (tierName.includes('platinum')) return 'tile-platinum';
if (tierName.includes('gold')) return 'tile-gold';
if (tierName.includes('silver')) return 'tile-silver';
if (tierName.includes('bronze')) return 'tile-bronze';
if (tierName.includes('band')) return 'tile-bandaid';
return 'tile-no-selection';
};
const getChipClass = (job: JobRecord): string => {
if (!hasSelectedOffer(job)) return 'chip-none';
const tierName = (job.selectedTierName || '').toLowerCase();
if (tierName.includes('platinum')) return 'chip-platinum';
if (tierName.includes('gold')) return 'chip-gold';
if (tierName.includes('silver')) return 'chip-silver';
if (tierName.includes('bronze')) return 'chip-bronze';
if (tierName.includes('band')) return 'chip-bandaid';
return 'chip-none';
};
const getRefId = (job: JobRecord): string => {
// If there's a selected offer, show its refId
if (job.selectedOffer?.refId) {
return job.selectedOffer.refId;
}
// Otherwise show first tier's refId with last character removed
const baseRefId = job.menus?.[0]?.tiers?.[0]?.offer?.refId || '';
return baseRefId.slice(0, -1);
};
const getJobHoursDisplay = (job: JobRecord): string => {
const baseHours = job.baseHours || 0;
const extraTime = job.extraTime || 0;
const totalHours = baseHours + extraTime;
if (extraTime > 0) {
return `${totalHours.toFixed(1)} hrs (${baseHours.toFixed(1)} + ${extraTime.toFixed(1)} extra)`;
}
return `${totalHours.toFixed(1)} hrs`;
};
// Get the selected tier from job.menus, or first tier if none selected
const getSelectedTier = (job: JobRecord): any => {
const tiers = job.menus?.[0]?.tiers;
if (!tiers || tiers.length === 0) return null;
// If job has selected offer, find matching tier
if (job.selectedOffer?.id) {
const selectedTier = tiers.find((t: any) => t.offer?.id === job.selectedOffer?.id);
if (selectedTier) return selectedTier;
}
// Default to first tier (highest/platinum)
return tiers[0];
};
const getTierContentItems = (job: JobRecord): any[] => {
const tier = getSelectedTier(job);
if (!tier) return [];
// Check if menuCopy exists and has contentItems
if (tier.menuCopy && Array.isArray(tier.menuCopy)) {
const allItems: any[] = [];
tier.menuCopy.forEach((copy: any) => {
if (copy.contentItems && Array.isArray(copy.contentItems)) {
allItems.push(...copy.contentItems);
}
});
return allItems;
}
// Fallback to contentItems if menuCopy doesn't exist
if (tier.contentItems && Array.isArray(tier.contentItems)) {
return tier.contentItems;
}
return [];
};
// Sort jobs: needs hours confirmed first, then by price (most expensive first)
const sortedJobs = computed(() => {
return [...tnfrStore.jobs].sort((a, b) => {
// Jobs needing hours confirmation come first
const aNeedsHours = !a.hoursConfirmed;
const bNeedsHours = !b.hoursConfirmed;
if (aNeedsHours && !bNeedsHours) return -1;
if (!aNeedsHours && bNeedsHours) return 1;
// Within same group, sort by price (highest first)
return getJobPrice(b) - getJobPrice(a);
});
});
// Check if any jobs have selected offers
const hasAnySelectedOffers = computed(() => {
return tnfrStore.jobs.some(job => hasSelectedOffer(job));
});
// Check if ALL jobs have selected offers
const allJobsHaveSelectedOffers = computed(() => {
return tnfrStore.jobs.length > 0 && tnfrStore.jobs.every(job => hasSelectedOffer(job));
});
// Button enable states
const canShowMenus = computed(() => {
return jobsNeedingHoursConfirmation.value === 0 && tnfrStore.jobs.length > 0;
});
const canCheckout = computed(() => {
return allJobsHaveSelectedOffers.value;
});
const canStartWork = computed(() => {
// TODO: Track checkout completion state in store
return false;
});
// Next step message based on current state
const nextStepMessage = computed(() => {
if (jobsNeedingHoursConfirmation.value > 0) {
return 'Confirm hours on all jobs to show menus';
}
if (!allJobsHaveSelectedOffers.value) {
return 'Show menus to customer in order to show invoice';
}
// TODO: Check if checkout completed
return 'Show invoice to customer in order to start work';
});
// Jobs that need hours confirmed
const jobsNeedingConfirmation = computed(() => {
return tnfrStore.jobs.filter(job => !job.hoursConfirmed);
});
// Count jobs that need hours confirmed
const jobsNeedingHoursConfirmation = computed(() => {
return jobsNeedingConfirmation.value.length;
});
// Calculate total price of all selected offers
const totalPrice = computed(() => {
return tnfrStore.jobs.reduce((total, job) => {
return total + getJobPrice(job);
}, 0);
});
const handleCheckout = async () => {
logExecution('handleCheckout');
// Set technician progress step 5 to true (moving to review invoice)
sessionStore.appProgress.step5 = true;
logExecution('tnfrStore.save');
await tnfrStore.save();
// Navigate to payment screen
router.push('/payment');
};
const handleStartWork = () => {
logExecution('handleStartWork');
// TODO: Navigate to work tracking screen
router.push('/invoice');
};
const showMenuAndClear = async (job: JobRecord) => {
logExecution('showMenuAndClear', [job.id]);
if (hasSelectedOffer(job)) {
await tnfrStore.setSelectedOffer(job.id, null, null, null, null, null);
}
router.push(`/menu/${job.id}`);
};
const removeFromCart = async (job: JobRecord) => {
logExecution('removeFromCart', [job.id]);
await tnfrStore.removeJob(job.id);
};
onMounted(async () => {
debugToolbar.value?.logLifecycle('onMounted');
logExecution('tnfrStore.load');
await tnfrStore.load();
});
// Ionic lifecycle hook - fires every time view becomes active (including back navigation)
onIonViewWillEnter(async () => {
debugToolbar.value?.logLifecycle('onIonViewWillEnter');
logExecution('tnfrStore.load');
await tnfrStore.load();
});
</script>
<style scoped>
.cart-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.header-section {
margin-bottom: 32px;
}
.title-container {
position: relative;
margin-bottom: 24px;
}
.title-icons {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 16px;
}
.title-icons ion-icon {
font-size: 48px;
}
.clickable-icon {
cursor: pointer;
transition: transform 0.2s ease, color 0.2s ease;
}
.clickable-icon:hover {
transform: scale(1.1);
color: var(--ion-color-primary-shade);
}
.clickable-icon:active {
transform: scale(0.95);
}
.page-title {
margin: 0;
color: #ffffff;
font-size: 48px;
font-weight: 700;
text-align: center;
font-variant: small-caps;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.construction-icon {
width: 48px;
height: 48px;
object-fit: contain;
}
.content-section {
padding: 0 20px 20px 20px;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 300px;
}
.empty-text {
color: var(--ion-color-medium);
font-size: 24px;
text-align: center;
font-variant: small-caps;
}
.jobs-list {
max-width: 1000px;
margin: 0 auto;
}
/* Cart Grid - tile layout like homepage */
.cart-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
max-width: 1000px;
margin: 0 auto;
}
.cart-tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 24px;
background: var(--ion-color-light);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 3px solid transparent;
min-height: 180px;
}
.cart-tile:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Tile tier colors */
.cart-tile.tile-no-selection {
border-color: var(--ion-color-medium);
}
.cart-tile.tile-platinum {
border-color: var(--menu-color-platinum, #3db4d6);
background: var(--menu-color-platinum-tint, #d0f5fe);
}
.cart-tile.tile-gold {
border-color: var(--menu-color-gold, #ffd83b);
background: var(--menu-color-gold-tint, #fde791);
}
.cart-tile.tile-silver {
border-color: var(--menu-color-silver, #bfbfbf);
background: var(--menu-color-silver-tint, #f0f0f0);
}
.cart-tile.tile-bronze {
border-color: var(--menu-color-bronze, #ffad2b);
background: var(--menu-color-bronze-tint, #fcc987);
}
.cart-tile.tile-bandaid {
border-color: var(--menu-color-bandaid, #ff8073);
background: var(--menu-color-bandaid-tint, #ffd1d1);
}
.cart-tile-name {
font-size: 20px;
font-weight: 600;
color: var(--ion-text-color);
text-align: center;
text-transform: capitalize;
}
.cart-tile-button {
padding: 10px 20px;
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.cart-tile-button:hover {
opacity: 0.85;
}
.tile-remove-icon {
position: absolute;
top: 8px;
right: 8px;
--padding-start: 4px;
--padding-end: 4px;
margin: 0;
}
/* Chip styles */
ion-chip.chip-none {
--background: transparent;
--color: var(--ion-color-medium);
border: 2px solid var(--ion-color-medium);
}
ion-chip.chip-platinum {
--background: var(--menu-color-platinum, #3db4d6);
--color: #fff;
}
ion-chip.chip-gold {
--background: var(--menu-color-gold, #ffd83b);
--color: #000;
}
ion-chip.chip-silver {
--background: var(--menu-color-silver, #bfbfbf);
--color: #000;
}
ion-chip.chip-bronze {
--background: var(--menu-color-bronze, #ffad2b);
--color: #000;
}
ion-chip.chip-bandaid {
--background: var(--menu-color-bandaid, #ff8073);
--color: #fff;
}
/* Add Job Tile */
.cart-tile-add {
border: 2px dashed var(--ion-color-medium);
background: transparent;
}
.cart-tile-add:hover {
border-color: var(--ion-color-primary);
background: rgba(var(--ion-color-primary-rgb), 0.05);
}
.cart-tile-add-icon {
font-size: 48px;
color: var(--ion-color-primary);
}
.total-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
margin-top: 24px;
background-color: var(--ion-background-color-step-50);
border-radius: 12px;
border: 1px solid var(--ion-color-medium);
}
.total-label {
font-size: 28px;
font-weight: 700;
color: var(--ion-text-color);
text-transform: uppercase;
letter-spacing: 1px;
}
.total-price {
font-size: 36px;
font-weight: 700;
color: var(--ion-text-color);
}
.checkout-button {
margin-top: 16px;
--border-radius: 12px;
height: 56px;
font-size: 20px;
font-weight: 600;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.cart-container {
padding: 16px;
}
.cart-grid {
gap: 12px;
}
.cart-tile {
padding: 24px 16px;
min-height: 160px;
}
.cart-tile-name {
font-size: 16px;
}
.cart-tile-add-icon {
font-size: 36px;
}
.cart-tile-button {
padding: 8px 16px;
font-size: 13px;
}
.total-section {
padding: 16px;
}
.total-label {
font-size: 20px;
}
.total-price {
font-size: 28px;
}
}
</style>