Hello from MCP server
<template>
<BaseLayout title="Review">
<div class="review-container">
<!-- Header Section -->
<div class="review-header">
<h1 class="review-title">Service Call Review</h1>
<div class="review-date">{{ formattedDate }}</div>
</div>
<!-- No active service call - show history view -->
<div v-if="!tnfrStore.startTime && !selectedLog" class="history-section">
<!-- Start Button -->
<div class="start-call-actions">
<ion-button
expand="block"
color="primary"
size="large"
@click="router.push('/directory')"
>
Start Service Call
</ion-button>
</div>
<!-- Speed Dial -->
<div class="job-counts-section">
<h2 class="section-title">Speed Dial</h2>
<div v-if="tnfrStore.speedDial.length > 0" class="job-counts-list">
<div v-for="refId in tnfrStore.speedDial" :key="refId" class="job-count-item">
<span class="job-count-name">{{ refId.replace('_problem', '') }} {{ getProblemName(refId) }}</span>
<span v-if="getJobByRefId(refId)?.count" class="job-count-badge">{{ getJobByRefId(refId)?.count }}</span>
<ion-button v-else fill="clear" size="small" @click="tnfrStore.removeFromSpeedDial(refId)">
<ion-icon slot="icon-only" :icon="removeOutline" color="danger" />
</ion-button>
</div>
</div>
<div class="speed-dial-add">
<ion-button shape="round" color="primary" @click="openAddModal">
<ion-icon slot="icon-only" :icon="addOutline" />
</ion-button>
</div>
</div>
<!-- Service Call History -->
<div class="history-list-section">
<h2 class="section-title">Service Call History</h2>
<div v-if="serviceCallLogs.length === 0" class="empty-state">
No service calls recorded yet
</div>
<div v-else class="history-list">
<div v-for="log in serviceCallLogs" :key="log.id" class="history-card" @click="selectLog(log)">
<div class="history-header">
<span class="history-date">{{ formatLogDate(log.created_at) }}</span>
<span class="history-user">{{ log.created_by_name }}</span>
</div>
<div class="history-stats">
<span class="history-stat">
<strong>{{ log.log_data.jobs?.length || 0 }}</strong> jobs
</span>
<span class="history-stat">
<strong>{{ log.log_data.paymentMethod || 'N/A' }}</strong>
</span>
<span v-if="log.log_data.applyDiscount" class="history-stat history-badge">SA</span>
<span v-if="log.log_data.showPlan" class="history-stat history-badge">Plan</span>
</div>
<div v-if="log.log_data.jobs?.length" class="history-jobs">
<div v-for="job in log.log_data.jobs" :key="job.id" class="history-job-item">
<span class="history-job-name">{{ job.selectedOffer?.refId }} {{ job.menus?.[0]?.name || job.title }}</span>
<span class="history-job-tier">{{ job.selectedTierName }}</span>
<span class="history-job-price">
<show-currency :currencyIn="parseFloat(job.selectedPrice) || 0" />
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Back button when viewing history -->
<div v-if="isViewingHistory" class="back-button-row">
<ion-button fill="clear" @click="clearSelectedLog">
← Back to History
</ion-button>
</div>
<!-- Summary Stats -->
<div v-if="hasActiveView" class="stats-row">
<div class="stat-item">
<div class="stat-value">{{ viewData.jobs.length }}</div>
<div class="stat-label">Jobs Completed</div>
</div>
<div class="stat-item">
<div class="stat-value">
<show-currency :currencyIn="totalPrice" />
</div>
<div class="stat-label">Total Revenue</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ viewData.paymentMethod || 'N/A' }}</div>
<div class="stat-label">Payment Method</div>
</div>
</div>
<!-- Jobs List -->
<div v-if="hasActiveView" class="review-section">
<h2 class="section-title">Jobs Performed</h2>
<div
v-for="job in viewData.jobs"
:key="job.id"
class="job-card"
>
<div class="job-header">
<div class="job-title-section">
<h3 class="job-title">{{ job.selectedOffer?.refId }} {{ job.menus?.[0]?.name }}</h3>
<div class="job-subtitle">{{ job.title || job.problem?.name || 'Untitled Job' }}</div>
</div>
<div class="job-price">
<show-currency :currencyIn="getJobPrice(job)" />
</div>
</div>
<div v-if="job.selectedTierName" class="job-tier">
<span class="tier-badge">{{ job.selectedTierName }}</span>
</div>
<div class="job-hours">
{{ formatHours(job) }}
</div>
<div class="job-adjustments">
<span class="adjustment-item">
<span class="adjustment-label">Extra Time:</span>
<span class="adjustment-value">{{ (job.extraTime || 0).toFixed(1) }} hrs</span>
</span>
<span class="adjustment-item">
<span class="adjustment-label">Extra Material:</span>
<span class="adjustment-value">${{ (job.extraMaterial || 0).toFixed(2) }}</span>
</span>
</div>
<ul v-if="job.selectedTierContent && job.selectedTierContent.length > 0" class="job-content-list">
<li v-for="(content, index) in job.selectedTierContent" :key="index" class="job-content-item">
{{ content }}
</li>
</ul>
<div v-if="job.notes && job.notes.length > 0" class="job-notes">
<div class="notes-label">Notes:</div>
<ul class="notes-list">
<li v-for="(note, index) in job.notes" :key="index" class="note-item">
{{ note }}
</li>
</ul>
</div>
<!-- Pricing Options -->
<div v-if="viewData.applyDiscount || viewData.showPlan" class="job-notes">
<div class="notes-label">Pricing Options:</div>
<ul class="notes-list">
<li v-if="viewData.applyDiscount" class="note-item">
Service Agreement Applied ({{ viewData.saDiscount }}% discount)
</li>
<li v-if="viewData.showPlan" class="note-item">
Payment Plan: {{ viewData.paymentPlanNumberPayments }} payments at {{ (viewData.paymentPlanRate * 100).toFixed(1) }}% annual rate
</li>
</ul>
</div>
<!-- Menu Presented -->
<div v-if="job.menus?.[0]?.tiers" class="job-notes">
<div class="notes-label">Menu Presented:</div>
<div v-for="tier in job.menus[0].tiers" :key="tier.id" class="menu-tier-section">
<div class="menu-tier-header">
{{ tier.offer?.refId }} {{ getTierTitle(tier) }} <strong><show-currency :currencyIn="tier.price || 0" /></strong>
<span v-if="job.selectedTierName === tier.name"> ✓</span>
</div>
<ul v-if="tier.menuCopy?.[0]?.contentItems?.length" class="notes-list">
<li v-for="item in tier.menuCopy[0].contentItems" :key="item.id" class="note-item">
{{ item.name || item.content }}
</li>
</ul>
</div>
</div>
<div class="job-card-corner">
<ion-button
size="small"
color="primary"
:router-link="`/job/${job.problem?.id}`"
>
View Job
</ion-button>
</div>
</div>
</div>
<!-- Payment Info -->
<div v-if="hasActiveView && viewData.invoice.paymentInfo" class="review-section">
<h2 class="section-title">Payment Notes</h2>
<p class="payment-notes">{{ viewData.invoice.paymentInfo }}</p>
</div>
<!-- Action Buttons (only for active service calls, not history) -->
<div v-if="tnfrStore.startTime && !isViewingHistory" class="review-actions">
<ion-button
expand="block"
color="success"
size="large"
@click="completeServiceCall"
>
Complete Service Call
</ion-button>
</div>
</div>
<!-- Add to Speed Dial Modal -->
<ion-modal :is-open="showAddModal" @didDismiss="showAddModal = false">
<ion-header>
<ion-toolbar>
<ion-title>Add to Speed Dial</ion-title>
<ion-buttons slot="end">
<ion-button @click="showAddModal = false">
<ion-icon slot="icon-only" :icon="closeOutline" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-searchbar
v-model="problemFilter"
placeholder="Filter by refId or name"
/>
<ion-list>
<ion-item
v-for="problem in filteredProblems"
:key="problem.id"
button
@click="addToSpeedDialAndClose(problem)"
>
<ion-label>
<h3>{{ problem.refId?.replace('_problem', '') }}</h3>
<p>{{ problem.name }}</p>
</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { IonButton, IonIcon, IonModal, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonButtons, IonSearchbar, toastController } from '@ionic/vue';
import { addOutline, closeOutline, removeOutline } from 'ionicons/icons';
import BaseLayout from '@/components/BaseLayout.vue';
import ShowCurrency from '@/components/ShowCurrency.vue';
import { useTnfrStore, type JobRecord } from '@/stores/tnfr';
import { list as listLogs, type LogEntryStored } from '@/framework/logs';
import { countJobs, type JobCount } from '@/tnfr/logs';
import { loadDirectory, type DirectoryData } from '@/framework/directory';
import type { ProblemsRecord } from '@/pocketbase-types';
const router = useRouter();
const route = useRoute();
const tnfrStore = useTnfrStore();
// Service call logs for history view
const serviceCallLogs = ref<LogEntryStored[]>([]);
const selectedLog = ref<LogEntryStored | null>(null);
const jobCounts = ref<JobCount[]>([]);
// Speed dial modal
const showAddModal = ref(false);
const allProblems = ref<ProblemsRecord[]>([]);
const problemFilter = ref('');
const filteredProblems = computed(() => {
const filter = problemFilter.value.toLowerCase().trim();
if (!filter) return allProblems.value;
return allProblems.value.filter(p =>
p.refId?.toLowerCase().includes(filter) ||
p.name?.toLowerCase().includes(filter)
);
});
async function openAddModal() {
const data = await loadDirectory();
allProblems.value = data.problems;
problemFilter.value = '';
showAddModal.value = true;
}
async function addToSpeedDialAndClose(problem: ProblemsRecord) {
if (problem.refId) {
await tnfrStore.addToSpeedDial(problem.refId);
}
showAddModal.value = false;
}
// Load service call logs and job counts
async function loadServiceCallLogs() {
serviceCallLogs.value = await listLogs({ log_type: 'service_call', limit: 50 });
jobCounts.value = await countJobs();
// Load all problems for speed dial lookup
const data = await loadDirectory();
allProblems.value = data.problems;
// Load speed dial from store (combines top 5 + saved prefs)
await tnfrStore.loadSpeedDial();
}
// Get problem name by refId
function getProblemName(refId: string): string {
const problem = allProblems.value.find(p => p.refId === refId);
return problem?.name || '';
}
// Get job data by refId from jobCounts
function getJobByRefId(refId: string): JobCount | undefined {
return jobCounts.value.find(job => job.refId === refId);
}
// Select a log to view
function selectLog(log: LogEntryStored) {
selectedLog.value = log;
}
// Clear selected log to go back to history
function clearSelectedLog() {
selectedLog.value = null;
}
// Computed view data - pulls from selectedLog or tnfrStore
const viewData = computed(() => {
if (selectedLog.value) {
const data = selectedLog.value.log_data;
return {
startTime: data.startTime || selectedLog.value.created_at,
jobs: data.jobs || [],
paymentMethod: data.paymentMethod || '',
applyDiscount: data.applyDiscount || false,
showPlan: data.showPlan || false,
saDiscount: data.saDiscount || 0,
paymentPlanNumberPayments: data.paymentPlanNumberPayments || 12,
paymentPlanRate: data.paymentPlanRate || 0.07,
invoice: data.invoice || { paymentInfo: '' },
};
}
return {
startTime: tnfrStore.startTime,
jobs: tnfrStore.jobs,
paymentMethod: tnfrStore.paymentMethod,
applyDiscount: tnfrStore.applyDiscount,
showPlan: tnfrStore.showPlan,
saDiscount: tnfrStore.saDiscount,
paymentPlanNumberPayments: tnfrStore.paymentPlanNumberPayments,
paymentPlanRate: tnfrStore.paymentPlanRate,
invoice: tnfrStore.invoice,
};
});
const isViewingHistory = computed(() => !!selectedLog.value);
const hasActiveView = computed(() => !!tnfrStore.startTime || !!selectedLog.value);
// Get current date formatted
const formattedDate = computed(() => {
const date = new Date();
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
// Calculate total price
const totalPrice = computed(() => {
return viewData.value.jobs.reduce((total: number, job: any) => {
return total + getJobPrice(job);
}, 0);
});
// Get job price
const getJobPrice = (job: JobRecord): number => {
if (job.selectedPrice) {
const price = parseFloat(job.selectedPrice);
return isNaN(price) ? 0 : price;
}
return 0;
};
// Format hours display
const formatHours = (job: JobRecord): string => {
const baseHours = job.baseHours || 0;
const extraTime = job.extraTime || 0;
const totalHours = baseHours + extraTime;
if (extraTime > 0) {
return `${totalHours.toFixed(1)} hours (${baseHours.toFixed(1)} + ${extraTime.toFixed(1)} extra)`;
}
return `${totalHours.toFixed(1)} hours`;
};
// Format log date
const formatLogDate = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Get tier title from contentItems
const getTierTitle = (tier: any): string => {
const titleItem = tier.contentItems?.find((item: any) =>
item.refId?.includes('_title')
);
return titleItem?.name || titleItem?.content || tier.name || '';
};
// Complete service call
const completeServiceCall = async () => {
// Set end time and save
tnfrStore.endTime = new Date().toISOString();
await tnfrStore.save();
// Log the service call before clearing
await tnfrStore.logServiceCall();
// Clear the session for a new service call
tnfrStore.clear();
await tnfrStore.save();
// Show success toast
const toast = await toastController.create({
message: 'Service Call Completed Successfully!',
duration: 2500,
color: 'success',
position: 'bottom',
});
await toast.present();
// Navigate to homepage
router.push('/');
};
onMounted(async () => {
await tnfrStore.load();
// Load service call logs if no active service call
if (!tnfrStore.startTime) {
await loadServiceCallLogs();
}
});
// Reload logs when navigating to this page
watch(() => route.path, async (newPath) => {
if (newPath === '/review' && !tnfrStore.startTime) {
selectedLog.value = null;
await loadServiceCallLogs();
}
});
</script>
<style scoped>
.review-container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
.review-header {
text-align: center;
margin-bottom: 40px;
}
.review-title {
margin: 0 0 16px 0;
color: var(--ion-text-color);
font-size: 36px;
font-weight: 700;
}
.review-date {
color: var(--ion-color-medium);
font-size: 18px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--ion-color-medium);
font-size: 18px;
}
.back-button-row {
margin-bottom: 16px;
}
/* History Section */
.history-section {
margin-top: 20px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.history-card {
background-color: var(--ion-background-color-step-50);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.history-card:hover {
background-color: var(--ion-background-color-step-100);
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.history-date {
font-size: 14px;
font-weight: 600;
color: var(--ion-text-color);
}
.history-user {
font-size: 13px;
color: var(--ion-color-medium);
}
.history-stats {
display: flex;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.history-stat {
font-size: 13px;
color: var(--ion-color-medium);
}
.history-stat strong {
color: var(--ion-text-color);
}
.history-badge {
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.history-jobs {
display: flex;
flex-direction: column;
gap: 8px;
}
.history-job-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background-color: var(--ion-background-color);
border-radius: 6px;
font-size: 13px;
}
.history-job-name {
flex: 1;
color: var(--ion-text-color);
font-weight: 500;
}
.history-job-tier {
color: var(--ion-color-medium);
font-size: 12px;
}
.history-job-price {
font-weight: 600;
color: var(--ion-text-color);
}
.start-call-actions {
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.start-call-actions ion-button {
--padding-top: 16px;
--padding-bottom: 16px;
font-weight: 600;
font-size: 16px;
}
.job-counts-section {
margin-top: 32px;
}
.history-list-section {
margin-top: 32px;
}
.job-counts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.job-count-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
}
.job-count-name {
font-size: 14px;
color: var(--ion-text-color);
}
.job-count-badge {
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
min-width: 32px;
text-align: center;
}
.speed-dial-add {
display: flex;
justify-content: center;
margin-top: 16px;
}
.stats-row {
display: flex;
justify-content: center;
gap: 32px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.stat-item {
text-align: center;
padding: 24px;
background-color: var(--ion-background-color-step-50);
border-radius: 12px;
min-width: 150px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--ion-text-color);
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: var(--ion-color-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.review-section {
margin-bottom: 32px;
}
.section-title {
font-size: 20px;
font-weight: 700;
color: var(--ion-text-color);
margin: 0 0 16px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.job-card {
position: relative;
padding: 20px;
padding-bottom: 60px;
background-color: var(--ion-background-color-step-50);
border-radius: 12px;
margin-bottom: 16px;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.job-title-section {
flex: 1;
}
.job-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--ion-text-color);
}
.job-subtitle {
font-size: 14px;
color: var(--ion-color-medium);
margin-top: 4px;
}
.job-price {
font-size: 24px;
font-weight: 700;
color: var(--ion-text-color);
}
.job-tier {
margin-bottom: 12px;
}
.tier-badge {
display: inline-block;
padding: 4px 12px;
background-color: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border-radius: 6px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.job-hours {
font-size: 14px;
color: var(--ion-color-medium);
margin-bottom: 12px;
}
.job-content-list {
margin: 0;
padding: 0;
list-style: none;
}
.job-content-item {
position: relative;
padding-left: 16px;
margin-bottom: 6px;
font-size: 14px;
color: var(--ion-color-medium-shade);
line-height: 1.4;
}
.job-content-item::before {
content: "•";
position: absolute;
left: 0;
color: var(--ion-color-primary);
}
.job-adjustments {
display: flex;
gap: 24px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.adjustment-item {
font-size: 14px;
}
.adjustment-label {
color: var(--ion-color-medium);
margin-right: 4px;
}
.adjustment-value {
color: var(--ion-text-color);
font-weight: 600;
}
.job-notes {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ion-color-light-shade);
}
.notes-label {
font-size: 14px;
font-weight: 600;
color: var(--ion-color-medium);
margin-bottom: 8px;
}
.notes-list {
margin: 0;
padding: 0;
list-style: none;
}
.note-item {
font-size: 14px;
color: var(--ion-text-color);
margin-bottom: 4px;
padding-left: 12px;
position: relative;
}
.note-item::before {
content: "-";
position: absolute;
left: 0;
color: var(--ion-color-medium);
}
.job-card-corner {
position: absolute;
bottom: 12px;
right: 12px;
}
.menu-tier-section {
margin-bottom: 12px;
}
.menu-tier-header {
font-size: 14px;
font-weight: 600;
color: var(--ion-text-color);
margin-bottom: 4px;
}
.payment-notes {
padding: 16px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
font-size: 16px;
color: var(--ion-text-color);
margin: 0;
}
.review-actions {
margin-top: 40px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.review-actions ion-button {
--padding-top: 16px;
--padding-bottom: 16px;
font-weight: 600;
font-size: 16px;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.review-container {
padding: 20px 16px;
}
.review-title {
font-size: 28px;
}
.stats-row {
gap: 16px;
}
.stat-item {
padding: 16px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.job-header {
flex-direction: column;
gap: 8px;
}
.job-price {
font-size: 20px;
}
}
</style>