Hello from MCP server

List Files | Just Commands | Repo | Logs

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