Hello from MCP server
<template>
<BaseLayout title="Invoice">
<div class="invoice-container">
<!-- Header Section -->
<div class="invoice-header">
<h1 class="invoice-title">Invoice</h1>
<div class="invoice-date">{{ invoiceData.createdAt ? formatDate(invoiceData.createdAt) : formattedDate }}</div>
</div>
<!-- Empty State -->
<div v-if="invoiceData.lineItems.length === 0" class="empty-state">
<p>No services selected</p>
<ion-button @click="router.push('/cart')">Back to Cart</ion-button>
</div>
<template v-else>
<!-- Copy All Button -->
<div class="copy-all-row">
<ion-button size="small" fill="outline" @click="copyAllToClipboard">
<ion-icon :icon="copyOutline" slot="start"></ion-icon>
Copy All
</ion-button>
</div>
<!-- Company Information Section -->
<div class="invoice-section">
<div class="section-header">
<h2 class="section-title">Company Information</h2>
<ion-icon :icon="copyOutline" class="copy-icon" @click="copySection('company')"></ion-icon>
</div>
<div class="section-content">
<p class="company-name">{{ organizationName }}</p>
<p class="company-detail">404 McGhee Drive, Dalton, GA 30721, United States</p>
<p class="company-detail">706-259-8892</p>
<p class="company-detail">info@thenewflatrate.com</p>
</div>
</div>
<!-- Customer Information Section -->
<div class="invoice-section">
<div class="section-header">
<h2 class="section-title">Customer Information</h2>
<div class="section-icons">
<ion-icon :icon="pencilOutline" class="edit-icon" :class="{ active: editingCustomer }" @click="editingCustomer = !editingCustomer"></ion-icon>
<ion-icon :icon="copyOutline" class="copy-icon" @click="copySection('customer')"></ion-icon>
</div>
</div>
<!-- Read-only view -->
<div v-if="!editingCustomer" class="section-content">
<template v-if="tnfrStore.invoice.customerName || tnfrStore.invoice.customerAddress || tnfrStore.invoice.customerPhone || tnfrStore.invoice.customerEmail">
<p v-if="tnfrStore.invoice.customerName" class="company-name">{{ tnfrStore.invoice.customerName }}</p>
<p v-if="tnfrStore.invoice.customerAddress" class="company-detail">{{ tnfrStore.invoice.customerAddress }}</p>
<p v-if="tnfrStore.invoice.customerPhone" class="company-detail">{{ tnfrStore.invoice.customerPhone }}</p>
<p v-if="tnfrStore.invoice.customerEmail" class="company-detail">{{ tnfrStore.invoice.customerEmail }}</p>
</template>
<p v-else class="empty-customer">No customer information added. Tap <ion-icon :icon="pencilOutline" class="inline-icon"></ion-icon> to add.</p>
</div>
<!-- Editable view -->
<div v-else class="section-content customer-form">
<ion-input
v-model="tnfrStore.invoice.customerName"
placeholder="Customer Name (optional)"
class="customer-input"
></ion-input>
<ion-input
v-model="tnfrStore.invoice.customerAddress"
placeholder="Address (optional)"
class="customer-input"
></ion-input>
<ion-input
v-model="tnfrStore.invoice.customerPhone"
placeholder="Phone (optional)"
class="customer-input"
></ion-input>
<ion-input
v-model="tnfrStore.invoice.customerEmail"
placeholder="Email (optional)"
class="customer-input"
></ion-input>
</div>
</div>
<!-- Line Items Section -->
<div class="invoice-section">
<div class="section-header">
<h2 class="section-title">Services Provided</h2>
<ion-icon :icon="copyOutline" class="copy-icon" @click="copySection('services')"></ion-icon>
</div>
<div class="section-content">
<div
v-for="(item, index) in invoiceData.lineItems"
:key="item.jobId"
class="line-item"
>
<div class="line-item-header">
<div class="line-item-details">
<h3 class="line-item-title">{{ item.offerRefId }} {{ item.menuName }}</h3>
<div class="line-item-meta">
<span class="line-item-tier">{{ item.tierName }}</span>
</div>
</div>
<div class="line-item-price">
<template v-if="invoiceData.applyDiscount && item.discountPrice">
<div class="price-discount">
<show-currency :currencyIn="item.discountPrice" />
</div>
<div class="price-original">
<show-currency :currencyIn="item.price" />
</div>
</template>
<template v-else>
<show-currency :currencyIn="item.price" />
</template>
</div>
</div>
<!-- Tier Content -->
<ul v-if="item.tierContent && item.tierContent.length > 0" class="tier-content-list">
<li v-for="(content, contentIndex) in item.tierContent" :key="contentIndex" class="tier-content-item">
{{ content }}
</li>
</ul>
</div>
</div>
</div>
<!-- Total Section -->
<div class="invoice-section total-section">
<div class="section-header">
<h2 class="section-title">Total</h2>
<ion-icon :icon="copyOutline" class="copy-icon" @click="copySection('total')"></ion-icon>
</div>
<div class="section-content">
<!-- Show subtotal and discount if service agreement applied -->
<template v-if="invoiceData.applyDiscount">
<div class="total-row subtotal-row">
<span class="total-label">Subtotal:</span>
<span class="total-value subtotal-value">
<show-currency :currencyIn="invoiceData.subtotal" />
</span>
</div>
<div class="total-row discount-row">
<span class="total-label">Service Agreement Discount:</span>
<span class="total-value discount-value">
-<show-currency :currencyIn="invoiceData.subtotal - invoiceData.discountSubtotal" />
</span>
</div>
</template>
<div class="total-row final-total-row">
<span class="total-label">Total:</span>
<span class="total-value final-total-value">
<show-currency :currencyIn="invoiceData.total" />
</span>
</div>
<!-- Payment Plan -->
<template v-if="invoiceData.showPlan && invoiceData.monthlyPayment">
<div class="payment-plan-row">
<span class="payment-plan-label">Payment Plan:</span>
<span class="payment-plan-value">
<show-currency :currencyIn="invoiceData.monthlyPayment" />/mo
<span class="payment-plan-terms">× {{ invoiceData.numberOfPayments }} payments</span>
</span>
</div>
</template>
</div>
</div>
<!-- Action Buttons -->
<div class="invoice-actions">
<ion-button
expand="block"
color="success"
size="large"
@click="showPaymentModal = true"
>
{{ tnfrStore.invoice.paymentConfirmed ? 'Update Payment Info' : 'Confirm Payment' }}
</ion-button>
</div>
</template>
</div>
<!-- Payment Confirmation Modal -->
<ion-modal :is-open="showPaymentModal" @didDismiss="showPaymentModal = false">
<ion-header>
<ion-toolbar>
<ion-title>Payment Information</ion-title>
<ion-buttons slot="end">
<ion-button @click="showPaymentModal = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="payment-modal-content">
<ion-textarea
v-model="paymentInfo"
:rows="4"
placeholder="Enter payment details (optional)..."
class="payment-info-textarea"
></ion-textarea>
<ion-button
expand="block"
color="success"
size="large"
@click="confirmPayment"
>
Confirm Payment
</ion-button>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import {
IonButton,
IonIcon,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
IonTextarea,
IonInput,
toastController,
} from '@ionic/vue';
import { copyOutline } from 'ionicons/icons';
import { mdPencil as pencilOutline } from '@/icons/customIcons';
import BaseLayout from '@/components/BaseLayout.vue';
import ShowCurrency from '@/components/ShowCurrency.vue';
import { useTnfrStore } from '@/stores/tnfr';
import { getApi } from '@/dataAccess/getApi';
const router = useRouter();
const tnfrStore = useTnfrStore();
const showPaymentModal = ref(false);
const organizationName = ref('Company Name');
const paymentInfo = ref('');
// Customer information editing state
const editingCustomer = ref(false);
// Get invoice data from store
const invoiceData = computed(() => tnfrStore.createInvoice());
// Get current date formatted
const formattedDate = computed(() => {
const date = new Date();
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
// Format date from ISO string
const formatDate = (isoDate: string) => {
const date = new Date(isoDate);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Copy all invoice content to clipboard
const copyAllToClipboard = async () => {
const invoiceText = buildInvoiceText();
try {
await navigator.clipboard.writeText(invoiceText);
const toast = await toastController.create({
message: 'Invoice copied to clipboard',
duration: 2000,
color: 'success',
position: 'bottom',
});
await toast.present();
} catch (err) {
console.error('[Invoice] Failed to copy to clipboard:', err);
}
};
// Build full invoice text
const buildInvoiceText = (): string => {
const data = invoiceData.value;
let text = '=== SERVICE INVOICE ===\n\n';
// Date
text += `Date: ${formattedDate.value}\n\n`;
// Company Information
text += '--- Company Information ---\n';
text += `${organizationName.value}\n`;
text += '404 McGhee Drive, Dalton, GA 30721, United States\n';
text += '706-259-8892\n';
text += 'info@thenewflatrate.com\n';
text += '\n';
// Customer Information
if (tnfrStore.invoice.customerName || tnfrStore.invoice.customerAddress || tnfrStore.invoice.customerPhone || tnfrStore.invoice.customerEmail) {
text += '--- Customer Information ---\n';
if (tnfrStore.invoice.customerName) text += `${tnfrStore.invoice.customerName}\n`;
if (tnfrStore.invoice.customerAddress) text += `${tnfrStore.invoice.customerAddress}\n`;
if (tnfrStore.invoice.customerPhone) text += `${tnfrStore.invoice.customerPhone}\n`;
if (tnfrStore.invoice.customerEmail) text += `${tnfrStore.invoice.customerEmail}\n`;
text += '\n';
}
// Services
text += '--- Services Provided ---\n';
data.lineItems.forEach((item, index) => {
text += `\n${index + 1}. ${item.offerRefId} ${item.menuName}\n`;
text += ` Tier: ${item.tierName}\n`;
if (item.tierContent && item.tierContent.length > 0) {
item.tierContent.forEach(content => {
text += ` • ${content}\n`;
});
}
if (data.applyDiscount && item.discountPrice) {
text += ` Price: $${item.discountPrice.toFixed(2)} (was $${item.price.toFixed(2)})\n`;
} else {
text += ` Price: $${item.price.toFixed(2)}\n`;
}
});
text += '\n';
// Total
text += '--- Total ---\n';
if (data.applyDiscount) {
text += `Subtotal: $${data.subtotal.toFixed(2)}\n`;
text += `Discount: -$${(data.subtotal - data.discountSubtotal).toFixed(2)}\n`;
}
text += `Total: $${data.total.toFixed(2)}\n`;
if (data.showPlan && data.monthlyPayment) {
text += `\nPayment Plan: $${data.monthlyPayment.toFixed(2)}/mo × ${data.numberOfPayments} payments\n`;
}
return text;
};
// Copy specific section to clipboard
const copySection = async (section: 'company' | 'customer' | 'services' | 'total') => {
const data = invoiceData.value;
let text = '';
if (section === 'company') {
text = 'Company Information:\n';
text += `${organizationName.value}\n`;
text += '404 McGhee Drive, Dalton, GA 30721, United States\n';
text += '706-259-8892\n';
text += 'info@thenewflatrate.com\n';
} else if (section === 'customer') {
text = 'Customer Information:\n';
if (tnfrStore.invoice.customerName) text += `${tnfrStore.invoice.customerName}\n`;
if (tnfrStore.invoice.customerAddress) text += `${tnfrStore.invoice.customerAddress}\n`;
if (tnfrStore.invoice.customerPhone) text += `${tnfrStore.invoice.customerPhone}\n`;
if (tnfrStore.invoice.customerEmail) text += `${tnfrStore.invoice.customerEmail}\n`;
if (!tnfrStore.invoice.customerName && !tnfrStore.invoice.customerAddress && !tnfrStore.invoice.customerPhone && !tnfrStore.invoice.customerEmail) {
text += '(No customer information provided)\n';
}
} else if (section === 'services') {
text = 'Services Provided:\n\n';
data.lineItems.forEach((item, index) => {
text += `${index + 1}. ${item.offerRefId} ${item.menuName}\n`;
text += ` Tier: ${item.tierName}\n`;
if (item.tierContent && item.tierContent.length > 0) {
item.tierContent.forEach(content => {
text += ` • ${content}\n`;
});
}
if (data.applyDiscount && item.discountPrice) {
text += ` Price: $${item.discountPrice.toFixed(2)} (was $${item.price.toFixed(2)})\n`;
} else {
text += ` Price: $${item.price.toFixed(2)}\n`;
}
text += '\n';
});
} else if (section === 'total') {
if (data.applyDiscount) {
text += `Subtotal: $${data.subtotal.toFixed(2)}\n`;
text += `Discount: -$${(data.subtotal - data.discountSubtotal).toFixed(2)}\n`;
}
text = `Total: $${data.total.toFixed(2)}`;
if (data.showPlan && data.monthlyPayment) {
text += `\nPayment Plan: $${data.monthlyPayment.toFixed(2)}/mo × ${data.numberOfPayments} payments`;
}
}
try {
await navigator.clipboard.writeText(text);
const toast = await toastController.create({
message: `${section.charAt(0).toUpperCase() + section.slice(1)} copied`,
duration: 1500,
color: 'success',
position: 'bottom',
});
await toast.present();
} catch (err) {
console.error('[Invoice] Failed to copy to clipboard:', err);
}
};
// Confirm payment handler
const confirmPayment = async () => {
tnfrStore.invoice.paymentConfirmed = true;
tnfrStore.invoice.paymentInfo = paymentInfo.value;
await tnfrStore.save();
showPaymentModal.value = false;
router.push('/review');
};
onMounted(async () => {
await tnfrStore.load();
// Pre-populate payment info from store
if (tnfrStore.invoice.paymentInfo) {
paymentInfo.value = tnfrStore.invoice.paymentInfo;
}
// Fetch organization name
try {
const { pb } = await getApi();
const orgId = pb.authStore.record?.activeOrg;
if (orgId) {
const org = await pb.collection('organizations').getOne(orgId);
if (org?.name) {
organizationName.value = org.name;
}
}
} catch (error) {
console.error('[Invoice] Failed to load organization name:', error);
}
});
</script>
<style scoped>
.invoice-container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
.invoice-header {
text-align: center;
margin-bottom: 40px;
}
.copy-all-row {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.invoice-title {
margin: 0 0 16px 0;
color: var(--ion-text-color);
font-size: 42px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.invoice-date {
color: var(--ion-color-medium);
font-size: 18px;
font-weight: 600;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
}
.empty-state p {
font-size: 18px;
color: var(--ion-color-medium);
}
.invoice-section {
background-color: var(--ion-background-color-step-50);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--ion-color-primary);
}
.section-title {
margin: 0;
color: var(--ion-text-color);
font-size: 24px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.section-icons {
display: flex;
gap: 16px;
align-items: center;
}
.copy-icon,
.edit-icon {
font-size: 28px;
color: var(--ion-color-primary);
cursor: pointer;
transition: transform 0.2s ease, color 0.2s ease;
}
.copy-icon:hover,
.edit-icon:hover {
transform: scale(1.2);
color: var(--ion-color-primary-shade);
}
.edit-icon.active {
color: var(--ion-color-success);
}
.empty-customer {
color: var(--ion-color-medium);
font-style: italic;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.inline-icon {
font-size: 16px;
vertical-align: middle;
}
.section-content {
color: var(--ion-text-color);
}
.company-name {
margin: 0 0 12px 0;
font-size: 22px;
font-weight: 700;
color: var(--ion-text-color);
}
.company-detail {
margin: 0 0 8px 0;
font-size: 16px;
color: var(--ion-text-color);
line-height: 1.5;
}
.customer-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.customer-input {
--background: var(--ion-background-color);
--color: var(--ion-text-color);
--placeholder-color: var(--ion-color-medium);
--padding-start: 16px;
--padding-end: 16px;
--padding-top: 12px;
--padding-bottom: 12px;
border: 1px solid var(--ion-color-light-shade);
border-radius: 8px;
font-size: 16px;
}
.customer-input:focus-within {
border-color: var(--ion-color-primary);
}
.line-item {
padding: 16px 0;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.line-item:last-child {
border-bottom: none;
}
.tier-content-list {
margin: 12px 0 0 0;
padding: 0;
list-style: none;
}
.tier-content-item {
position: relative;
padding-left: 16px;
margin-bottom: 6px;
font-size: 14px;
color: var(--ion-color-medium-shade);
line-height: 1.4;
}
.tier-content-item::before {
content: "•";
position: absolute;
left: 0;
color: var(--ion-color-primary);
}
.line-item-header {
display: flex;
gap: 16px;
align-items: flex-start;
}
.line-item-details {
flex: 1;
}
.line-item-title {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 700;
color: var(--ion-text-color);
}
.line-item-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.line-item-tier {
display: inline-block;
padding: 4px 12px;
background-color: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border-radius: 6px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
.line-item-price {
font-size: 28px;
font-weight: 700;
color: var(--ion-text-color);
white-space: nowrap;
text-align: right;
}
.price-discount {
font-size: 28px;
font-weight: 700;
color: var(--ion-text-color);
}
.price-original {
font-size: 18px;
color: var(--ion-color-medium);
text-decoration: line-through;
}
.total-section {
background-color: var(--ion-background-color);
border: 1px solid var(--ion-color-medium);
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.subtotal-row {
border-bottom: 1px solid var(--ion-color-light-shade);
}
.discount-row {
border-bottom: 1px solid var(--ion-color-light-shade);
}
.discount-row .total-value {
color: var(--ion-text-color);
}
.final-total-row {
padding-top: 16px;
}
.total-label {
font-size: 20px;
font-weight: 600;
color: var(--ion-text-color);
}
.final-total-row .total-label {
font-size: 28px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.total-value {
font-size: 24px;
font-weight: 700;
color: var(--ion-text-color);
}
.final-total-value {
font-size: 36px;
color: var(--ion-text-color);
}
.payment-plan-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding-top: 16px;
border-top: 2px dashed var(--ion-color-medium);
}
.payment-plan-label {
font-size: 18px;
font-weight: 600;
color: var(--ion-color-medium);
}
.payment-plan-value {
font-size: 24px;
font-weight: 700;
color: var(--ion-color-primary);
}
.payment-plan-terms {
font-size: 14px;
font-weight: 500;
color: var(--ion-color-medium);
margin-left: 8px;
}
.invoice-actions {
margin-top: 40px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.invoice-actions ion-button {
--padding-top: 16px;
--padding-bottom: 16px;
font-weight: 600;
font-size: 16px;
}
/* Payment Modal Styles */
.payment-modal-content {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
}
.payment-info-textarea {
--background: var(--ion-color-light);
--color: var(--ion-text-color);
--padding-start: 16px;
--padding-end: 16px;
--padding-top: 12px;
--padding-bottom: 12px;
border: 1px solid var(--ion-color-medium);
border-radius: 8px;
font-size: 16px;
line-height: 1.5;
}
.payment-modal-content ion-button {
--padding-top: 20px;
--padding-bottom: 20px;
font-weight: 600;
font-size: 18px;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.invoice-container {
padding: 20px 16px;
}
.invoice-title {
font-size: 32px;
}
.invoice-date {
font-size: 16px;
}
.section-title {
font-size: 20px;
}
.line-item-header {
flex-wrap: wrap;
}
.line-item-price {
width: 100%;
margin-top: 12px;
text-align: left;
}
.price-discount {
font-size: 24px;
}
.total-label {
font-size: 18px;
}
.final-total-row .total-label {
font-size: 22px;
}
.total-value {
font-size: 20px;
}
.final-total-value {
font-size: 28px;
}
.payment-plan-value {
font-size: 20px;
}
}
</style>