Hello from MCP server
<template>
<BaseLayout title="Price List">
<div class="price-list-container">
<h1 class="page-title">Price List</h1>
<div class="content-section">
<div v-if="loading" class="loading-state">
<ion-spinner name="crescent"></ion-spinner>
<p>Loading price list...</p>
</div>
<div v-else-if="offers.length === 0 && !testMode" class="empty-state">
<p>No offers found.</p>
</div>
<div v-else-if="!testMode" class="formula-info">
To learn more about the pricing formula and variables, go to <router-link to="/framework">/framework</router-link> and click "Calculate Price"
</div>
<div v-if="!loading && offers.length > 0 && !testMode" class="results-info">
Showing {{ offers.length }} of {{ offersTotal }} offers
<button v-if="offers.length < offersTotal" class="show-all-btn" @click="showAllOffers">Show All</button>
</div>
<div v-if="!loading && (offers.length > 0 || testMode)" class="formula-params">
<div class="param-item">
<span class="param-label">Hourly Fee</span>
<span class="param-value">{{ formulaParams.hourlyFeeConverted }}</span>
</div>
<div class="param-item">
<span class="param-label">Sales Tax</span>
<span class="param-value">{{ formulaParams.salesTax }}</span>
</div>
<div class="param-item">
<span class="param-label">Service Call Fee</span>
<span class="param-value">{{ formulaParams.serviceCallFeeConverted }}</span>
</div>
<div class="param-item">
<span class="param-label">Extra Hours</span>
<input
v-model.number="extraHours"
type="number"
step="0.5"
min="0"
class="param-input"
/>
</div>
<div class="param-item">
<span class="param-label">Extra Hr Discount</span>
<input
v-model.number="extraHoursDiscount"
type="number"
step="0.1"
min="0"
max="1"
class="param-input"
/>
</div>
<label class="toggle-label">
<input type="checkbox" v-model="useTierMultiplier" />
Use Tier Multiplier
</label>
<button v-if="!testMode" class="recalculate-btn" @click="reloadPriceList">Recalculate</button>
<button v-if="!csvFileName" class="test-btn" @click="triggerCsvUpload">Test CSV</button>
<input
ref="csvInput"
type="file"
accept=".csv"
style="display: none"
@change="handleCsvUpload"
/>
</div>
<div v-if="csvFileName" class="csv-file-banner">
<span class="csv-file-name">{{ csvFileName }}</span>
<button v-if="!testMode" class="parse-btn" @click="parseCsvFile">Parse</button>
<button class="clear-btn" @click="clearCsvFile">Clear</button>
<span v-if="testMode" class="csv-status">{{ testOffers.length }} items loaded</span>
</div>
<ion-grid v-if="displayOffers.length > 0" class="data-table">
<ion-row class="table-header">
<ion-col size="2">Name</ion-col>
<ion-col size="1.5">Time</ion-col>
<ion-col size="1.5" class="text-right">Mat Fee</ion-col>
<ion-col size="1.5" class="text-right">Time Fee</ion-col>
<ion-col size="1.5" class="text-right">All Fees</ion-col>
<ion-col size="1.5" class="text-right">Imp Rate</ion-col>
<ion-col size="1.25" class="text-right">Extra</ion-col>
<ion-col size="1.25" class="text-right">Price</ion-col>
</ion-row>
<ion-row
v-for="offer in displayOffers"
:key="offer.id"
class="table-row"
:class="{ clickable: !testMode }"
@click="!testMode && openModal(offer)"
>
<ion-col size="2">
<div class="cell-primary">{{ offer.name || offer.refId || offer.id }}</div>
<div class="cell-secondary">
<span v-if="offer.tierMultiplier != null">
Band-Aid × {{ offer.tierMultiplier.toFixed(2) }}
</span>
</div>
</ion-col>
<template v-if="!offer.isBandAid && useTierMultiplier && extraHours > 0">
<ion-col size="1.5"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.5" class="text-right"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.5" class="text-right"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.5" class="text-right"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.5" class="text-right"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.25" class="text-right"><div class="cell-primary">---</div></ion-col>
<ion-col size="1.25" class="text-right">
<div class="cell-primary">{{ offer.derivedFinalPrice || '—' }}</div>
</ion-col>
</template>
<template v-else>
<ion-col size="1.5">
<div class="cell-primary">{{ offer.totalHours }} hrs</div>
<div class="cell-secondary">
<span v-for="(item, idx) in (offer.costsTime || [])" :key="item.id" class="ref-tag">
{{ item.refId || item.name }}<span v-if="idx < (offer.costsTime?.length || 0) - 1">, </span>
</span>
</div>
</ion-col>
<ion-col size="1.5" class="text-right">
<div class="cell-primary">{{ offer.derivedVarsConverted?.materialFee?.formatted || '—' }}</div>
<div class="cell-secondary">
<span v-for="(item, idx) in (offer.costsMaterial || [])" :key="item.id" class="ref-tag">
{{ item.refId || item.name }}<span v-if="idx < (offer.costsMaterial?.length || 0) - 1">, </span>
</span>
</div>
</ion-col>
<ion-col size="1.5" class="text-right">
<div class="cell-primary">{{ offer.derivedVarsConverted?.timeFee?.formatted || '—' }}</div>
</ion-col>
<ion-col size="1.5" class="text-right">
<div class="cell-primary">{{ offer.derivedVarsConverted?.tierPrice?.formatted || '—' }}</div>
</ion-col>
<ion-col size="1.5" class="text-right">
<div class="cell-primary">{{ offer.derivedVarsConverted?.impliedHourlyRate?.formatted || '—' }}</div>
</ion-col>
<ion-col size="1.25" class="text-right">
<div class="cell-primary">{{ extraHours }} hrs</div>
</ion-col>
<ion-col size="1.25" class="text-right">
<div class="cell-primary">{{ offer.finalPriceConverted?.formatted || '—' }}</div>
</ion-col>
</template>
</ion-row>
</ion-grid>
</div>
</div>
<!-- Detail Modal -->
<ion-modal :is-open="isModalOpen" @didDismiss="closeModal">
<ion-header>
<ion-toolbar>
<ion-title>{{ selectedOffer?.name || selectedOffer?.refId || 'Offer Details' }}</ion-title>
<ion-buttons slot="end">
<ion-button v-if="pendingChanges.length > 0" @click="showChangesetPreview = !showChangesetPreview">
{{ showChangesetPreview ? 'Edit' : 'Save' }}
</ion-button>
<ion-button @click="closeModal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- Changeset Preview -->
<div v-if="showChangesetPreview" class="changeset-preview">
<div class="commit-controls">
<select v-model="selectedBookId" class="book-select">
<option value="">-- Select book --</option>
<option v-for="book in availableBooks" :key="book.id" :value="book.id">
{{ book.refId || book.name || book.id }}
</option>
</select>
<ion-button
:disabled="!selectedBookId || pendingChanges.length === 0 || isCommitting"
@click="commitPendingChanges"
>
{{ isCommitting ? 'Committing...' : 'Commit' }}
</ion-button>
</div>
<div v-if="commitStatus" :class="['commit-status', commitStatus.success ? 'success' : 'error']">
{{ commitStatus.message }}
</div>
<h3>Changeset Preview</h3>
<pre class="changeset-json">{{ JSON.stringify(pendingChanges, null, 2) }}</pre>
</div>
<div v-else-if="selectedOffer" class="modal-content">
<!-- Name Section -->
<div class="detail-section">
<div class="detail-header">
<h3>Name</h3>
<ion-icon
:icon="isEditing.name ? checkmarkOutline : pencilOutline"
class="edit-icon"
@click="toggleEdit('name')"
></ion-icon>
</div>
<div v-if="isEditing.name" class="edit-field">
<ion-input v-model="editValues.name" fill="outline"></ion-input>
</div>
<div v-else class="detail-value">{{ selectedOffer.name || selectedOffer.refId || selectedOffer.id }}</div>
</div>
<!-- Time Costs Section -->
<div class="detail-section">
<div class="detail-header">
<h3>Time Costs</h3>
<ion-icon
:icon="isEditing.costsTime ? checkmarkOutline : pencilOutline"
class="edit-icon"
@click="toggleEdit('costsTime')"
></ion-icon>
</div>
<div class="detail-summary">Total: {{ selectedOffer.totalHours }} hrs</div>
<div v-if="isEditing.costsTime" class="edit-list">
<div v-for="(item, idx) in editValues.costsTime" :key="idx" class="edit-list-item">
<ion-input v-model="item.refId" placeholder="RefId" fill="outline" class="edit-input-small"></ion-input>
<ion-input v-model.number="item.hours" type="number" placeholder="Hours" fill="outline" class="edit-input-small"></ion-input>
</div>
<!-- Add new time cost -->
<div v-if="!showAddTimeCost" class="add-item-trigger" @click="showAddTimeCost = true">
<ion-icon :icon="addCircleOutline" class="add-icon"></ion-icon>
</div>
<div v-else class="add-item-form">
<ion-input v-model="newTimeCost.refId" placeholder="RefId" fill="outline" class="edit-input-small"></ion-input>
<ion-input v-model.number="newTimeCost.hours" type="number" step="0.5" placeholder="Hours" fill="outline" class="edit-input-small"></ion-input>
<ion-button size="small" @click="addTimeCost">Add</ion-button>
<ion-button size="small" fill="clear" color="medium" @click="cancelAddTimeCost">Cancel</ion-button>
</div>
</div>
<div v-else class="detail-list">
<div v-for="item in (selectedOffer.costsTime || [])" :key="item.id" class="detail-list-item">
<span class="item-ref">{{ item.refId || item.name }}</span>
<span class="item-value">{{ item.hours }} hrs</span>
</div>
<div v-if="(selectedOffer.costsTime?.length || 0) === 0" class="empty-list">No time costs</div>
</div>
</div>
<!-- Material Costs Section -->
<div class="detail-section">
<div class="detail-header">
<h3>Material Costs</h3>
<ion-button
v-if="!showCreateMaterial && !showMaterialPicker"
size="small"
fill="outline"
@click="showCreateMaterial = true"
>
Create New
</ion-button>
</div>
<!-- Create New Material Form -->
<div v-if="showCreateMaterial" class="create-material-form">
<div class="form-row">
<ion-input
v-model="newMaterial.refId"
placeholder="RefId (required)"
fill="outline"
class="form-input"
></ion-input>
</div>
<div class="form-row">
<ion-input
v-model="newMaterial.name"
placeholder="Name (optional)"
fill="outline"
class="form-input"
></ion-input>
</div>
<div class="form-row">
<ion-input
v-model.number="newMaterial.quantity"
type="number"
step="0.01"
placeholder="Quantity"
fill="outline"
class="form-input"
></ion-input>
<span class="currency-hint">in your currency</span>
</div>
<div class="form-actions">
<ion-button size="small" @click="createNewMaterial">Create & Add</ion-button>
<ion-button size="small" fill="clear" color="medium" @click="cancelCreateMaterial">Cancel</ion-button>
</div>
</div>
<!-- Material Picker View -->
<div v-if="showMaterialPicker" class="material-picker">
<div class="picker-header">
<ion-searchbar
v-model="materialSearchQuery"
placeholder="Search materials..."
class="picker-search"
></ion-searchbar>
<ion-button fill="clear" size="small" @click="closeMaterialPicker">
<ion-icon :icon="closeOutline"></ion-icon>
</ion-button>
</div>
<div class="picker-list">
<div
v-for="item in filteredCostsMaterial"
:key="item.id"
class="picker-item"
@click="selectMaterialCost(item)"
>
<span class="picker-item-name">{{ item.refId || item.name }}</span>
<span class="picker-item-value">{{ item.quantityConverted?.formatted || item.quantity }}</span>
</div>
<div v-if="filteredCostsMaterial.length === 0" class="picker-empty">
No materials found
</div>
</div>
</div>
<!-- Material List -->
<div v-else class="detail-list">
<div v-for="item in displayMaterials" :key="item.id" class="detail-list-item" :class="{ 'pending-item': item.isPending, 'removed-item': item.isRemoved }">
<span class="item-ref">
{{ item.refId || item.name }}
<span v-if="item.isPending" class="pending-badge">new</span>
<span v-if="item.isRemoved" class="removed-badge">removing</span>
</span>
<span class="item-actions">
<span class="item-value">{{ item.quantityConverted?.formatted || item.quantity || '—' }}</span>
<ion-icon
v-if="!item.isRemoved"
:icon="removeCircleOutline"
class="remove-icon"
@click="removeMaterialCost(item)"
></ion-icon>
</span>
</div>
<div v-if="displayMaterials.length === 0" class="empty-list">No material costs</div>
<div class="add-item-trigger" @click="openMaterialPicker">
<ion-icon :icon="addCircleOutline" class="add-icon"></ion-icon>
</div>
</div>
<div class="price-factors">
<div class="factor-row">
<span class="factor-label">Sales Tax</span>
<span class="factor-value">× {{ formulaParams.salesTaxMultiplier }}</span>
</div>
<div class="factor-row">
<span class="factor-label">Markup</span>
<span class="factor-value">× {{ selectedOffer.derivedVars?.markupMultiplier?.toFixed(2) || '—' }}</span>
</div>
<div class="factor-row factor-total">
<span class="factor-label">Material Fee</span>
<span class="factor-value">{{ selectedOffer.derivedVarsConverted?.materialFee?.formatted || '—' }}</span>
</div>
</div>
</div>
<!-- Final Price Section -->
<div class="detail-section">
<div class="detail-header">
<h3>Final Price</h3>
</div>
<div class="detail-value price-value">{{ selectedOffer.finalPriceConverted?.formatted || '—' }}</div>
</div>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import {
IonGrid,
IonRow,
IonCol,
IonSpinner,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonContent,
IonIcon,
IonInput,
IonSearchbar,
} from "@ionic/vue";
import { pencilOutline, checkmarkOutline, addCircleOutline, closeOutline, removeCircleOutline } from "ionicons/icons";
import BaseLayout from "@/components/BaseLayout.vue";
import { loadPriceList, type EnrichedOffer } from "@/framework/priceList";
import { loadBooks, commitChanges, downloadChanges } from "@/framework/changes";
import { loadCurrencies, convert, convertToPrefs, type CurrenciesData } from "@/framework/currencies";
import { getPricingVars } from "@/framework/org";
import tnfrLegacyWithHours from "@/lib/tnfrLegacyWithHours";
interface DisplayOffer extends EnrichedOffer {
totalHours: number;
tierMultiplier: number | null;
isBandAid: boolean;
derivedFinalPrice: string | null; // For non-band-aid tiers: band-aid price × multiplier
}
const loading = ref(true);
const offers = ref<DisplayOffer[]>([]);
const offersTotal = ref(0);
const offersLimit = ref(25);
const currenciesData = ref<CurrenciesData | null>(null);
const selectedCurrency = ref("usd");
const currencySymbol = ref("$");
const formulaParams = ref({
hourlyFee: 0,
hourlyFeeConverted: "",
salesTax: "",
salesTaxMultiplier: 1,
serviceCallFee: 0,
serviceCallFeeConverted: "",
});
const extraHours = ref(0);
const extraHoursDiscount = ref(0.4);
const useTierMultiplier = ref(false);
const orgVariables = ref<{ hourlyFee: number; salesTax: number; serviceCallFee: number } | null>(null);
// Test mode state
const testMode = ref(false);
const testOffers = ref<DisplayOffer[]>([]);
const csvInput = ref<HTMLInputElement | null>(null);
const csvFileName = ref<string | null>(null);
const csvFileContent = ref<string | null>(null);
// Display either normal offers or test offers
const displayOffers = computed(() => {
if (testMode.value) {
return testOffers.value || [];
}
return offers.value || [];
});
// Modal state
const isModalOpen = ref(false);
const selectedOffer = ref<DisplayOffer | null>(null);
// Edit state
const isEditing = reactive({
name: false,
costsTime: false,
costsMaterial: false,
});
const editValues = reactive({
name: "",
costsTime: [] as { refId: string; hours: number }[],
costsMaterial: [] as { refId: string; quantity: string }[],
});
// Add new time cost state
const showAddTimeCost = ref(false);
const newTimeCost = reactive({
refId: "",
hours: 0,
});
// Material cost picker state
const showMaterialPicker = ref(false);
const allCostsMaterial = ref<any[]>([]);
const materialSearchQuery = ref("");
// Changeset state
const pendingChanges = ref<any[]>([]);
const showChangesetPreview = ref(false);
const availableBooks = ref<any[]>([]);
const selectedBookId = ref("");
const commitStatus = ref<{ success: boolean; message: string } | null>(null);
const isCommitting = ref(false);
// Create new material state
const showCreateMaterial = ref(false);
const newMaterial = reactive({
refId: "",
name: "",
quantity: 0,
});
// Computed: materials including pending additions and removals
const displayMaterials = computed(() => {
if (!selectedOffer.value) return [];
const originalMaterials = selectedOffer.value.costsMaterial;
const originalRefIds = originalMaterials.map(m => m.refId);
// Find pending offer change
const pendingChange = pendingChanges.value.find(
c => c.collection === "offers" && c.data.refId === selectedOffer.value?.refId
);
// If no pending change, return originals as-is
if (!pendingChange) {
return originalMaterials.map(m => ({ ...m }));
}
const pendingRefs = pendingChange.data.refs?.find((r: any) => r.targetField === "costsMaterial");
if (!pendingRefs) {
return originalMaterials.map(m => ({ ...m }));
}
const finalRefIds: string[] = pendingRefs.refIds;
const materials: any[] = [];
// Add original materials, marking removed ones
for (const mat of originalMaterials) {
const isRemoved = !mat.refId || !finalRefIds.includes(mat.refId);
materials.push({ ...mat, isRemoved });
}
// Add new materials (in finalRefIds but not in original)
const newRefIds = finalRefIds.filter(id => !originalRefIds.includes(id));
for (const refId of newRefIds) {
const matItem = allCostsMaterial.value.find(m => m.refId === refId);
if (matItem) {
materials.push({ ...matItem, isPending: true });
}
}
return materials;
});
function openModal(offer: DisplayOffer) {
selectedOffer.value = offer;
// Initialize edit values
editValues.name = (offer as any).name || (offer as any).refId || "";
editValues.costsTime = offer.costsTime.map(item => ({
refId: item.refId || item.name || "",
hours: item.hours || 0,
}));
editValues.costsMaterial = offer.costsMaterial.map(item => ({
refId: item.refId || item.name || "",
quantity: item.quantity || "0",
}));
// Reset edit state
isEditing.name = false;
isEditing.costsTime = false;
isEditing.costsMaterial = false;
// Reset add time cost form
showAddTimeCost.value = false;
newTimeCost.refId = "";
newTimeCost.hours = 0;
// Reset material picker
showMaterialPicker.value = false;
materialSearchQuery.value = "";
// Reset changeset
pendingChanges.value = [];
showChangesetPreview.value = false;
// Reset create material form
showCreateMaterial.value = false;
newMaterial.refId = "";
newMaterial.name = "";
newMaterial.quantity = 0;
// Reset commit state
selectedBookId.value = "";
commitStatus.value = null;
isCommitting.value = false;
// Load books if not already loaded
if (availableBooks.value.length === 0) {
loadBooks().then(books => {
availableBooks.value = books;
});
}
isModalOpen.value = true;
}
function closeModal() {
isModalOpen.value = false;
selectedOffer.value = null;
}
function toggleEdit(field: "name" | "costsTime" | "costsMaterial") {
isEditing[field] = !isEditing[field];
// Reset add form when toggling off
if (!isEditing[field] && field === "costsTime") {
showAddTimeCost.value = false;
newTimeCost.refId = "";
newTimeCost.hours = 0;
}
if (!isEditing[field] && field === "costsMaterial") {
showMaterialPicker.value = false;
materialSearchQuery.value = "";
}
}
function addTimeCost() {
if (newTimeCost.refId.trim()) {
editValues.costsTime.push({
refId: newTimeCost.refId.trim(),
hours: newTimeCost.hours || 0,
});
// Reset form
newTimeCost.refId = "";
newTimeCost.hours = 0;
showAddTimeCost.value = false;
}
}
function cancelAddTimeCost() {
newTimeCost.refId = "";
newTimeCost.hours = 0;
showAddTimeCost.value = false;
}
// Material cost picker functions
async function openMaterialPicker() {
const { getDb } = await import("@/dataAccess/getDb");
const db = await getDb();
const rawMaterials = (await db.selectAll("costsMaterial")) || [];
// Convert quantities to user's preferred currency
allCostsMaterial.value = await Promise.all(
rawMaterials.map(async (item: any) => ({
...item,
quantityConverted: item.quantity ? await convertToPrefs(Number(item.quantity)) : null,
}))
);
materialSearchQuery.value = "";
showMaterialPicker.value = true;
}
const filteredCostsMaterial = computed(() => {
if (!materialSearchQuery.value.trim()) {
return allCostsMaterial.value;
}
const query = materialSearchQuery.value.toLowerCase();
return allCostsMaterial.value.filter(item =>
(item.refId?.toLowerCase().includes(query)) ||
(item.name?.toLowerCase().includes(query))
);
});
function selectMaterialCost(item: any) {
if (!selectedOffer.value) return;
console.log("[selectMaterialCost] selectedOffer:", selectedOffer.value);
console.log("[selectMaterialCost] offer id:", selectedOffer.value.id, "refId:", selectedOffer.value.refId);
// Get all current refIds including pending additions
const allRefIds = displayMaterials.value.map(c => c.refId || c.name || "");
// Check if already in offer or pending
if (allRefIds.includes(item.refId)) {
closeMaterialPicker();
return;
}
// Add the new refId to the list
const newRefIds = [...allRefIds, item.refId];
// Create an update changeset item
const change = {
collection: "offers",
operation: "update",
data: {
refId: selectedOffer.value.refId,
refs: [
{
collection: "costsMaterial",
refIds: newRefIds,
targetField: "costsMaterial",
},
],
},
};
// Replace any existing offer update or add new one
const existingIdx = pendingChanges.value.findIndex(
c => c.collection === "offers" && c.data.refId === selectedOffer.value?.refId
);
if (existingIdx >= 0) {
pendingChanges.value[existingIdx] = change;
} else {
pendingChanges.value.push(change);
}
closeMaterialPicker();
}
function closeMaterialPicker() {
showMaterialPicker.value = false;
materialSearchQuery.value = "";
}
async function createNewMaterial() {
if (!newMaterial.refId.trim() || !selectedOffer.value) return;
// Convert quantity from user's preferred currency to base currency
const { convertFromPrefs } = await import("@/framework/currencies");
const baseQuantity = await convertFromPrefs(newMaterial.quantity);
// Create the costsMaterial record changeset
const createChange = {
collection: "costsMaterial",
operation: "create",
data: {
refId: newMaterial.refId.trim(),
name: newMaterial.name.trim() || newMaterial.refId.trim(),
quantity: baseQuantity ?? newMaterial.quantity,
},
};
pendingChanges.value.push(createChange);
// Also add it to allCostsMaterial so it shows up in displayMaterials
const convertedQuantity = await convertToPrefs(baseQuantity ?? newMaterial.quantity);
allCostsMaterial.value.push({
id: `new-${Date.now()}`,
refId: newMaterial.refId.trim(),
name: newMaterial.name.trim() || newMaterial.refId.trim(),
quantity: baseQuantity ?? newMaterial.quantity,
quantityConverted: convertedQuantity,
});
// Now add this new material to the offer
const allRefIds = displayMaterials.value
.filter(m => !m.isRemoved)
.map(c => c.refId || c.name || "");
const newRefIds = [...allRefIds, newMaterial.refId.trim()];
// Create/update the offer changeset
const offerChange = {
collection: "offers",
operation: "update",
data: {
refId: selectedOffer.value.refId,
refs: [
{
collection: "costsMaterial",
refIds: newRefIds,
targetField: "costsMaterial",
},
],
},
};
const existingIdx = pendingChanges.value.findIndex(
c => c.collection === "offers" && c.data.refId === selectedOffer.value?.refId
);
if (existingIdx >= 0) {
pendingChanges.value[existingIdx] = offerChange;
} else {
pendingChanges.value.push(offerChange);
}
// Reset form
newMaterial.refId = "";
newMaterial.name = "";
newMaterial.quantity = 0;
showCreateMaterial.value = false;
}
function cancelCreateMaterial() {
newMaterial.refId = "";
newMaterial.name = "";
newMaterial.quantity = 0;
showCreateMaterial.value = false;
}
async function commitPendingChanges() {
if (pendingChanges.value.length === 0 || !selectedBookId.value) return;
isCommitting.value = true;
commitStatus.value = null;
try {
const result = await commitChanges(pendingChanges.value, {
bookId: selectedBookId.value,
});
if (result.success) {
commitStatus.value = {
success: true,
message: `Committed! Record ID: ${result.recordId}`,
};
// Clear pending changes after successful commit
pendingChanges.value = [];
// Reload the price list to reflect changes
await reloadPriceList();
// Close the modal after a short delay to show success message
setTimeout(() => {
closeModal();
}, 1000);
} else {
commitStatus.value = {
success: false,
message: result.error || "Commit failed",
};
}
} catch (error) {
commitStatus.value = {
success: false,
message: error instanceof Error ? error.message : String(error),
};
} finally {
isCommitting.value = false;
}
}
function removeMaterialCost(item: any) {
if (!selectedOffer.value) return;
const refIdToRemove = item.refId;
// Get current final refIds (either from pending change or from original)
let currentFinalRefIds: string[];
const existingChange = pendingChanges.value.find(
c => c.collection === "offers" && c.data.refId === selectedOffer.value?.refId
);
if (existingChange) {
const pendingRefs = existingChange.data.refs?.find((r: any) => r.targetField === "costsMaterial");
currentFinalRefIds = pendingRefs ? [...pendingRefs.refIds] : [];
} else {
currentFinalRefIds = selectedOffer.value.costsMaterial.map(c => c.refId).filter((id): id is string => !!id);
}
// Remove the item
const newRefIds = currentFinalRefIds.filter(id => id !== refIdToRemove);
// Create/update the changeset
const change = {
collection: "offers",
operation: "update",
data: {
refId: selectedOffer.value.refId,
refs: [
{
collection: "costsMaterial",
refIds: newRefIds,
targetField: "costsMaterial",
},
],
},
};
const existingIdx = pendingChanges.value.findIndex(
c => c.collection === "offers" && c.data.refId === selectedOffer.value?.refId
);
if (existingIdx >= 0) {
pendingChanges.value[existingIdx] = change;
} else {
pendingChanges.value.push(change);
}
}
// Format a single currency value (for formula params)
function formatValue(value: number): string {
if (!currenciesData.value) return "—";
const converted = convert(currenciesData.value.rates, "silver", selectedCurrency.value, value);
return `${currencySymbol.value}${Math.ceil(converted).toLocaleString()}`;
}
// Create formula factory with additionalTime, discount, and org vars set
function createFormulaFactory() {
return () => {
const formula = tnfrLegacyWithHours();
formula.vars.additionalTime.value = extraHours.value;
formula.vars.additionalHourDiscount.value = extraHoursDiscount.value;
// Apply org variables if available
if (orgVariables.value) {
formula.vars.hourlyFee.value = orgVariables.value.hourlyFee;
formula.vars.salesTax.value = orgVariables.value.salesTax;
formula.vars.serviceCallFee.value = orgVariables.value.serviceCallFee;
}
return formula;
};
}
async function reloadPriceList() {
if (!currenciesData.value) {
return;
}
try {
const priceListResult = await loadPriceList({ formulaFactory: createFormulaFactory(), limit: offersLimit.value });
offersTotal.value = priceListResult.total;
// Build lookup of band-aid tier prices and final prices by menuName
const bandAidData = new Map<string, { tierPrice: number; finalPrice: number }>();
for (const offer of priceListResult.offers) {
if (offer.menuTier === "bandaid" && offer.menuName && offer.derivedVars?.tierPrice && offer.finalPrice) {
bandAidData.set(offer.menuName, {
tierPrice: offer.derivedVars.tierPrice,
finalPrice: offer.finalPrice,
});
}
}
// Enrich offers with computed totals and tier multiplier
offers.value = priceListResult.offers.map(offer => {
let tierMultiplier: number | null = null;
let derivedFinalPrice: string | null = null;
const isBandAid = offer.menuTier === "bandaid";
if (offer.menuName && offer.derivedVars?.tierPrice) {
const bandAid = bandAidData.get(offer.menuName);
if (bandAid && bandAid.tierPrice > 0) {
tierMultiplier = offer.derivedVars.tierPrice / bandAid.tierPrice;
// For non-band-aid tiers, calculate derived final price
if (!isBandAid && useTierMultiplier.value && extraHours.value > 0) {
const derivedPrice = bandAid.finalPrice * tierMultiplier;
const convertedPrice = convert(
currenciesData.value!.rates,
"silver",
selectedCurrency.value,
derivedPrice
);
derivedFinalPrice = `${currencySymbol.value}${Math.ceil(convertedPrice).toLocaleString()}`;
}
}
}
return {
...offer,
totalHours: offer.costsTime.reduce((sum, item) => sum + (item.hours || 0), 0),
tierMultiplier,
isBandAid,
derivedFinalPrice,
};
});
} catch (e) {
console.error("[PriceList] Error loading price list:", e);
}
}
async function showAllOffers() {
offersLimit.value = 10000;
await reloadPriceList();
}
// CSV Test Mode functions
function triggerCsvUpload() {
csvInput.value?.click();
}
async function handleCsvUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
csvFileName.value = file.name;
csvFileContent.value = await file.text();
} catch (e) {
console.error("[PriceList] Error reading CSV:", e);
alert("Error reading CSV file");
}
// Reset the input so the same file can be uploaded again
input.value = "";
}
function clearCsvFile() {
csvFileName.value = null;
csvFileContent.value = null;
testMode.value = false;
testOffers.value = [];
}
async function parseCsvFile() {
console.log("[PriceList] parseCsvFile called, content length:", csvFileContent.value?.length);
if (!csvFileContent.value) {
console.log("[PriceList] No CSV content");
return;
}
try {
console.log("[PriceList] CSV content preview:", csvFileContent.value.substring(0, 200));
const rows = parseCsv(csvFileContent.value);
console.log("[PriceList] Parsed rows:", rows);
if (rows.length === 0) {
alert("No valid data found in CSV. Make sure it has 'task_code' and 'price' columns.");
return;
}
// Convert CSV rows to test offers (just display the task_code and price)
const testData: DisplayOffer[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const price = parseFloat(row.price) || 0;
testData.push({
id: `test-${i}`,
refId: row.taskCode,
name: row.taskCode,
costsTime: [],
costsMaterial: [],
menuName: null,
menuTier: null,
finalPrice: price,
finalPriceConverted: {
value: price,
formatted: `$${price.toFixed(2)}`,
symbol: "$",
targetCurrency: "usd",
},
formulaSteps: null,
derivedVars: null,
derivedVarsConverted: null,
totalHours: 0,
tierMultiplier: null,
isBandAid: false,
derivedFinalPrice: null,
});
}
console.log("[PriceList] Created test offers:", testData.length);
testOffers.value = testData;
testMode.value = true;
console.log("[PriceList] testMode set to true, testOffers:", testOffers.value.length);
} catch (e) {
console.error("[PriceList] Error parsing CSV:", e);
alert("Error parsing CSV file");
}
}
function parseCsv(text: string): Array<{ taskCode: string; price: string }> {
const lines = text.trim().split("\n");
console.log("[parseCsv] Lines:", lines.length, "First line:", lines[0]);
if (lines.length < 2) return []; // Need header + at least one row
const header = lines[0].split(",").map(h => h.trim().toLowerCase());
console.log("[parseCsv] Header:", header);
const taskCodeIdx = header.findIndex(h => h === "task_code" || h === "taskcode" || h === "task code");
const priceIdx = header.findIndex(h => h === "price");
console.log("[parseCsv] Column indices - task_code:", taskCodeIdx, "price:", priceIdx);
if (taskCodeIdx < 0 || priceIdx < 0) {
console.error("[parseCsv] Required columns not found. Need 'task_code' and 'price'");
return [];
}
const rows: Array<{ taskCode: string; price: string }> = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(",").map(c => c.trim());
if (cols.length === 0 || (cols.length === 1 && cols[0] === "")) continue;
const taskCode = cols[taskCodeIdx] || "";
const price = cols[priceIdx] || "";
if (taskCode && price) {
rows.push({ taskCode, price });
console.log("[parseCsv] Row", i, ":", taskCode, "=", price);
}
}
return rows;
}
onMounted(async () => {
try {
// Load currencies first
const currencies = await loadCurrencies();
currenciesData.value = currencies;
// Get currency symbol
const currency = currencies.currencies.find(c => c.refId === selectedCurrency.value);
currencySymbol.value = currency?.symbol || "$";
// Get pricing variables from org (with defaults)
const pricingVars = await getPricingVars();
const { hourlyFee, salesTax, serviceCallFee } = pricingVars;
// Store org vars for use in formula factory
orgVariables.value = { hourlyFee, salesTax, serviceCallFee };
formulaParams.value = {
hourlyFee,
hourlyFeeConverted: formatValue(hourlyFee),
salesTax: `${((salesTax - 1) * 100).toFixed(0)}%`,
salesTaxMultiplier: salesTax,
serviceCallFee,
serviceCallFeeConverted: formatValue(serviceCallFee),
};
// Load price list
await reloadPriceList();
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.price-list-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
margin: 0 0 24px 0;
color: var(--ion-text-color);
font-size: 32px;
font-weight: 700;
text-align: center;
}
.content-section {
padding: 0 20px 20px 20px;
}
.loading-state,
.empty-state {
text-align: center;
padding: 40px;
color: var(--ion-color-medium);
}
.loading-state ion-spinner {
margin-bottom: 16px;
}
.formula-info {
font-size: 13px;
color: var(--ion-color-medium);
margin-bottom: 12px;
}
.formula-info a {
color: #0000EE;
text-decoration: underline;
}
.formula-info a:hover {
text-decoration: underline;
}
.results-info {
font-size: 13px;
color: var(--ion-color-medium);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.show-all-btn {
padding: 4px 12px;
font-size: 12px;
background: var(--ion-color-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.show-all-btn:hover {
background: var(--ion-color-primary-shade);
}
.formula-params {
display: flex;
gap: 24px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--ion-color-light);
border-radius: 8px;
}
.param-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.param-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--ion-color-medium);
}
.param-value {
font-size: 16px;
font-weight: 500;
color: var(--ion-color-dark);
}
.param-input {
width: 80px;
padding: 4px 8px;
font-size: 16px;
font-weight: 500;
border: 1px solid var(--ion-color-medium);
border-radius: 4px;
text-align: center;
}
.toggle-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--ion-color-dark);
cursor: pointer;
margin-left: auto;
}
.toggle-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.recalculate-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 16px;
}
.recalculate-btn:hover {
background: var(--ion-color-primary-shade);
}
.test-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
background: var(--ion-color-secondary);
color: var(--ion-color-secondary-contrast);
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 8px;
}
.test-btn:hover {
background: var(--ion-color-secondary-shade);
}
.csv-file-banner {
display: flex;
align-items: center;
gap: 12px;
background: var(--ion-color-light);
border: 1px solid var(--ion-color-medium);
padding: 8px 16px;
border-radius: 4px;
margin-bottom: 16px;
}
.csv-file-name {
font-weight: 500;
font-family: monospace;
color: var(--ion-color-dark);
}
.csv-status {
color: var(--ion-color-success);
font-weight: 500;
margin-left: auto;
}
.parse-btn {
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
background: var(--ion-color-success);
color: var(--ion-color-success-contrast);
border: none;
border-radius: 4px;
cursor: pointer;
}
.parse-btn:hover {
background: var(--ion-color-success-shade);
}
.clear-btn {
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
background: var(--ion-color-medium);
color: var(--ion-color-medium-contrast);
border: none;
border-radius: 4px;
cursor: pointer;
}
.clear-btn:hover {
background: var(--ion-color-medium-shade);
}
.data-table {
background: var(--ion-color-light);
border-radius: 8px;
overflow: hidden;
}
.table-header {
background: var(--ion-color-primary);
color: var(--ion-color-primary-contrast);
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table-header ion-col {
padding: 12px 16px;
}
.table-row {
border-bottom: 1px solid var(--ion-color-light-shade);
}
.table-row:last-child {
border-bottom: none;
}
.table-row ion-col {
padding: 12px 16px;
color: var(--ion-color-dark);
font-size: 14px;
}
.table-row.clickable {
cursor: pointer;
}
.table-row:hover {
background: var(--ion-color-light-shade);
}
.text-right {
text-align: right;
}
.cell-primary {
font-weight: 500;
color: var(--ion-color-dark);
}
.cell-secondary {
font-size: 12px;
color: var(--ion-color-medium);
margin-top: 4px;
line-height: 1.4;
}
.ref-tag {
white-space: nowrap;
}
/* Modal Styles */
.modal-content {
padding: 8px;
}
.detail-section {
background: var(--ion-color-light);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.detail-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--ion-color-medium);
}
.edit-icon {
font-size: 20px;
color: var(--ion-color-primary);
cursor: pointer;
padding: 4px;
transition: color 0.2s ease;
}
.edit-icon:hover {
color: var(--ion-color-primary-shade);
}
.detail-value {
font-size: 18px;
font-weight: 500;
color: var(--ion-color-dark);
}
.price-value {
font-size: 24px;
font-weight: 700;
color: var(--ion-color-primary);
}
.detail-summary {
font-size: 16px;
font-weight: 600;
color: var(--ion-color-dark);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--ion-color-light-shade);
border-radius: 6px;
}
.item-ref {
font-size: 14px;
color: var(--ion-color-dark);
}
.item-value {
font-size: 14px;
font-weight: 500;
color: var(--ion-color-primary);
}
.empty-list {
font-size: 14px;
color: var(--ion-color-medium);
font-style: italic;
padding: 8px;
}
.price-factors {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--ion-color-medium-shade);
display: flex;
flex-direction: column;
gap: 6px;
}
.factor-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.factor-label {
color: var(--ion-color-medium);
}
.factor-value {
font-weight: 600;
color: var(--ion-color-dark);
}
.factor-total {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--ion-color-light-shade);
}
.factor-total .factor-value {
color: var(--ion-color-primary);
}
.edit-field {
margin-top: 8px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-list-item {
display: flex;
gap: 8px;
}
.edit-input-small {
flex: 1;
}
.add-item-trigger {
display: flex;
justify-content: center;
padding: 8px;
cursor: pointer;
}
.add-icon {
font-size: 28px;
color: var(--ion-color-primary);
transition: color 0.2s ease;
}
.add-icon:hover {
color: var(--ion-color-primary-shade);
}
.add-item-form {
display: flex;
gap: 8px;
align-items: center;
padding: 8px;
background: var(--ion-color-light-shade);
border-radius: 6px;
margin-top: 8px;
}
/* Material Picker */
.material-picker {
background: var(--ion-color-light);
border-radius: 8px;
border: 1px solid var(--ion-color-light-shade);
}
.picker-header {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.picker-search {
flex: 1;
--background: var(--ion-color-light);
--box-shadow: none;
padding: 0;
}
.picker-list {
max-height: 250px;
overflow-y: auto;
}
.picker-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--ion-color-light-shade);
}
.picker-item:last-child {
border-bottom: none;
}
.picker-item:hover {
background: var(--ion-color-light-shade);
}
.picker-item-name {
font-size: 14px;
font-weight: 500;
}
.picker-item-value {
font-size: 13px;
color: var(--ion-color-medium);
font-family: var(--ion-font-family-mono, monospace);
}
.picker-empty {
padding: 16px;
text-align: center;
color: var(--ion-color-medium);
font-style: italic;
}
/* Pending items */
.pending-item {
background: var(--ion-color-success-tint);
}
.pending-badge {
font-size: 10px;
background: var(--ion-color-success);
color: white;
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
text-transform: uppercase;
font-weight: 600;
}
/* Removed items */
.removed-item {
background: var(--ion-color-danger-tint);
opacity: 0.7;
}
.removed-item .item-ref,
.removed-item .item-value {
text-decoration: line-through;
}
.removed-badge {
font-size: 10px;
background: var(--ion-color-danger);
color: white;
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
text-transform: uppercase;
font-weight: 600;
}
.item-actions {
display: flex;
align-items: center;
gap: 8px;
}
.remove-icon {
font-size: 20px;
color: var(--ion-color-danger);
cursor: pointer;
transition: color 0.2s ease;
}
.remove-icon:hover {
color: var(--ion-color-danger-shade);
}
/* Changeset Preview */
.changeset-preview {
padding: 8px;
}
.commit-controls {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.book-select {
flex: 1;
padding: 10px 12px;
font-size: 14px;
border: 1px solid var(--ion-color-light-shade);
border-radius: 6px;
background: var(--ion-color-light);
max-width: 300px;
}
.commit-status {
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.commit-status.success {
background: var(--ion-color-success-tint);
color: var(--ion-color-success-shade);
}
.commit-status.error {
background: var(--ion-color-danger-tint);
color: var(--ion-color-danger-shade);
}
.changeset-preview h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
}
.changeset-json {
background: var(--ion-color-light);
border: 1px solid var(--ion-color-light-shade);
border-radius: 8px;
padding: 12px;
font-size: 12px;
font-family: monospace;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Create Material Form */
.create-material-form {
background: var(--ion-color-light);
border: 1px solid var(--ion-color-light-shade);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.create-material-form .form-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.create-material-form .form-input {
flex: 1;
--background: var(--ion-color-light-tint);
}
.create-material-form .currency-hint {
font-size: 12px;
color: var(--ion-color-medium);
white-space: nowrap;
}
.create-material-form .form-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
</style>