Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<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>