Hello from MCP server
import { defineStore } from "pinia";
import type { OffersRecord, ProblemsRecord, ProblemTagsRecord } from "@/pocketbase-types.ts";
import { getDb } from "@/dataAccess/getDb";
// Define menu modifications type for job-specific edits
export interface MenuModifications {
menuTitle?: string; // Override for menu title
tierModifications?: {
[tierId: string]: {
offerTitle?: string; // Override for offer name/title
offerDetails?: string; // Override for offer details/description
hidden?: boolean; // Whether to hide this tier/offer from display
};
};
}
// Define the job structure type
export interface JobRecord {
id: string;
title: string;
problem: ProblemsRecord;
baseHours: number; // Base hours from the lowest tier offer (band-aid estimate)
extraTime: number; // Additional hours from check-hours slider adjustment (0 if no adjustment)
extraMaterial: number;
selectedOffer: OffersRecord;
selectedPrice: string;
selectedTierName?: string; // Name of the selected tier (Platinum, Gold, etc.)
selectedTierTitle?: string; // Title/description of the selected tier
notes: string[];
completedClipboards?: number[]; // Array of completed clipboard step numbers (1, 2, 3)
menuModifications?: MenuModifications; // Local overrides for menu display
menuData?: any; // Cached menu data with full tier details (fetched once at job creation)
checklistIssues?: string[]; // Array of checklist items where "No" was clicked
}
// Define theme colors type (legacy - stores flat color set)
export interface SessionThemeColors {
primary: string;
secondary: string;
tertiary: string;
success: string;
warning: string;
danger: string;
light: string;
medium: string;
dark: string;
background: string;
text: string;
}
// Define menu theme colors type
export interface MenuThemeColors {
platinum: string;
gold: string;
silver: string;
bronze: string;
bandaid: string;
}
// Define app progress type for 5-step process
export interface AppProgress {
step1: string | boolean; // Find job
step2: string | boolean; // Confirm job
step3: string | boolean; // Confirm hours
step4: string | boolean; // Pass to customer
step5: string | boolean; // Review invoice
}
// Define customer progress type for customer-facing pages
// This is a dynamic array where:
// - Indices 0 to N-1 represent each menu/job to review
// - Index N represents the review step
// - Index N+1 represents the payment step
export type CustomerProgress = (string | boolean)[];
/* Session Persistence
*
* All actions that modify the session state should also call this.save() to
* save the state to the database. If the calling code modifies the state
* directly, it should also call save(), and when useSessionStore is called,
* the calling code should also call load(). Possibly, we could automatically
* include load() in the useSessionStore function, but going to err on the side
* of simplicity for now.
*
* The database table only has the columns id, state, created and updated.
*
* So in normal use, we will always query for the most recently updated
* session, and in addition we can create a screen to browse all sessions,
* preview their data, and choose want to activate as the current session
* (change it's updated timestamp so it is the most recent).
*
*/
// Example of what a job structure looks like (commented out - no longer used as default)
// const defaultJob = {
// id: "userId-unixtimestamp",
// title: "Job Title, e.g. Kitchen Sink Drain Cleaning",
// problem: {} as ProblemsRecord,
// extraTime: 0, // Additional time in hours
// extraMaterial: 0, // Additional material cost in base currency
// selectedOffer: {} as OffersRecord,
// selectedPrice: "we store long floats as strings and convert later",
// notes: ["my first note", "my second note"], // array of strings
// };
export const useSessionStore = defineStore("session", {
state: () => ({
db: null as Awaited<ReturnType<typeof getDb>> | null,
sessionId: 0,
startTime: "",
activeJob: "userId-unixtimestampppp",
jobs: [] as JobRecord[], // Start with empty jobs array
endTime: "",
applyDiscount: false, // Whether to apply customer discount
serviceCallsCount: 0, // Total service calls for the day
menusShownCount: 0, // Total menus shown for the day
menusShownGoal: 3, // Company goal: menus per service call (configurable)
higherTierChosenCount: 0, // Number of times a higher tier option was chosen
serviceCallsWithMenusShown: 0, // Number of service calls where at least one menu was shown
// Theme customization
themeColors: {} as SessionThemeColors,
menuThemeColors: {} as MenuThemeColors,
// Search-related state
problems: [] as ProblemsRecord[], // All available problems/jobs
problemTags: [] as ProblemTagsRecord[], // All available tags
selectedTags: [] as string[], // Currently selected tag IDs for filtering
appliedSearchQuery: "", // Current search query applied to filters
selectionHistory: [] as Array<{ tags: string[], search: string }>, // Undo history
// Track if real jobs have ever been added (to hide mock data permanently once real jobs added)
hasHadRealJobs: false,
// Payment method
paymentMethod: "", // Selected payment method (Credit, Check, etc.)
// Edit mode - when true, clicking offers logs data instead of selecting them
editMode: false,
// Secret mode - when true, shows bulk upload dialog for CSV data
secretMode: false,
// Debug mode - in-memory only, resets on page refresh
debugMode: false,
// App progress tracker - 5-step process
appProgress: {
step1: false,
step2: false,
step3: false,
step4: false,
step5: false,
} as AppProgress,
// Customer progress tracker - customer-facing pages
customerProgress: [] as CustomerProgress,
}),
actions: {
async initDb() {
if (!this.db) {
this.db = await getDb();
}
},
async load() {
await this.initDb();
if (this.db) {
const current = (await this.db.sessions.getCurrent()) ?? [];
if (current[0]) {
const loadedState = JSON.parse(current[0].state);
console.log('[Session Store] LOAD - jobs from DB:', loadedState.jobs?.map((j: any) => ({
id: j.id,
problemName: j.problem?.name,
baseHours: j.baseHours,
extraTime: j.extraTime,
checklistIssues: j.checklistIssues,
})));
// Preserve in-memory only state
const currentDebugMode = this.debugMode;
this.$state = loadedState;
this.debugMode = currentDebugMode;
this.sessionId = current[0].id;
await this.initDb();
}
}
},
async save() {
await this.initDb();
if (this.db) {
const stateCopy = { ...this.$state };
delete stateCopy.db;
delete (stateCopy as any).debugMode; // Don't persist debug mode
console.log('[Session Store] SAVE - jobs being saved:', this.jobs?.map((j: any) => ({
id: j.id,
problemName: j.problem?.name,
baseHours: j.baseHours,
extraTime: j.extraTime,
checklistIssues: j.checklistIssues,
})));
await this.db.sessions.save(JSON.stringify(stateCopy), this.sessionId);
// We want to make sure we update the id, so that we update this
// session in future saves, instead of creating multiple db records.
// There is probably a more efficient way of doing this, or more
// user-friendly, like returning the new id from the db operation, but
// this is pretty simplle and easy to understand, the problem is just
// making sure that we remember to vake this call to load().
if (this.sessionId == 0) {
await this.load();
}
}
},
clear() {
this.startTime = "";
this.activeJob = "";
this.jobs = [];
this.hasHadRealJobs = false;
this.appProgress = {
step1: false,
step2: false,
step3: false,
step4: false,
step5: false,
};
this.customerProgress = [];
},
async addJob(jobId: string) {
const job = this.jobs.find((e) => e.id == jobId);
// this should never happen as we are using milliseconds for the job id
// but just for good measure...
if (job == undefined) {
// Start the session timer when adding the first job if no start time exists
if (this.jobs.length === 0 && !this.startTime) {
this.startTime = new Date().toISOString();
}
this.jobs.push({
id: jobId,
title: "", // Job Title, e.g. Kitchen Sink Drain Cleaning
problem: {} as ProblemsRecord,
baseHours: 0, // Base hours from the lowest tier offer
extraTime: 0, // Additional time in hours
extraMaterial: 0, // Additional material cost in base currency
selectedOffer: {} as OffersRecord,
selectedPrice: "",
notes: [], // array of strings
} as JobRecord);
// Mark that we've had real jobs added (to hide mock data permanently)
this.hasHadRealJobs = true;
await this.save();
}
},
async fetchMenuDataForProblem(problem: ProblemsRecord): Promise<any | null> {
if (!problem.menus || !this.db) {
return null;
}
try {
// Parse menu IDs (can be string or array)
let menuIds: string[] = [];
if (typeof problem.menus === 'string') {
try {
menuIds = JSON.parse(problem.menus);
} catch (e) {
menuIds = [problem.menus];
}
} else if (Array.isArray(problem.menus)) {
menuIds = problem.menus;
}
// Fetch first menu data with full tier details
if (menuIds.length > 0) {
const menuData = await this.db.menus.byMenuId(menuIds[0]);
return menuData || null;
}
} catch (error) {
console.error('[Session Store] Error fetching menu data:', error);
return null;
}
return null;
},
async addProblemToJob(jobId: string, problem: ProblemsRecord) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.problem = problem;
// Fetch and cache menu data for this job
const menuData = await this.fetchMenuDataForProblem(problem);
if (menuData) {
job.menuData = menuData;
console.log('[Session Store] Cached menu data for job:', jobId);
// Set baseHours from the band-aid tier (last tier)
if (menuData.tiers && menuData.tiers.length > 0) {
const bandAidTier = menuData.tiers[menuData.tiers.length - 1];
if (bandAidTier.offer && this.db) {
try {
const offerData = await this.db.offerData(bandAidTier.offer.id);
if (offerData && offerData.costsTime) {
const totalHours = offerData.costsTime.reduce(
(sum: number, cost: any) => sum + (cost.hours || 0),
0
);
job.baseHours = totalHours;
console.log('[Session Store] Set baseHours for job:', jobId, totalHours);
}
} catch (error) {
console.error('[Session Store] Error fetching offer data for baseHours:', error);
}
}
}
}
await this.save();
}
},
async updateBaseHours(jobId: string, hours: number) {
console.log('[STORE] updateBaseHours called:', { jobId, hours });
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
console.log('[STORE] Found job, updating baseHours:', {
before: job.baseHours,
after: hours
});
job.baseHours = hours;
await this.save();
console.log('[STORE] Base hours updated and saved');
} else {
console.log('[STORE] Job not found with id:', jobId);
}
},
async updateCompletedClipboards(jobId: string, clipboards: number[]) {
console.log('[STORE] updateCompletedClipboards called:', { jobId, clipboards });
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
console.log('[STORE] Found job, updating completedClipboards:', {
before: job.completedClipboards,
after: clipboards
});
job.completedClipboards = clipboards;
await this.save();
console.log('[STORE] Completed clipboards updated and saved');
} else {
console.log('[STORE] Job not found with id:', jobId);
}
},
async updateExtraTime(jobId: string, hours: number) {
console.log('[STORE] updateExtraTime called:', { jobId, hours });
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
console.log('[STORE] Found job, updating extraTime:', {
before: job.extraTime,
after: hours,
baseHours: job.baseHours,
estimatedHours: job.baseHours ? (job.baseHours * hours).toFixed(1) : '---'
});
job.extraTime = hours;
await this.save();
console.log('[STORE] Job updated and saved');
} else {
console.log('[STORE] Job not found with id:', jobId);
}
},
async updateExtraMaterial(jobId: string, cost: number) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.extraMaterial = cost;
await this.save();
}
},
async updateJobName(jobId: string, name: string) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.title = name;
await this.save();
}
},
async updateJobNotes(jobId: string, notes: string) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
// Convert notes string to array format that the job structure expects
job.notes = notes ? [notes] : [];
await this.save();
}
},
async updateChecklistIssues(jobId: string, issues: string[]) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.checklistIssues = issues;
await this.save();
}
},
async updateJobMenuData(jobId: string, menuData: any) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.menuData = menuData;
await this.save();
}
},
async updateJobMenuModifications(jobId: string, modifications: MenuModifications) {
const job = this.jobs.find((e) => e.id == jobId);
if (job) {
job.menuModifications = modifications;
await this.save();
}
},
async updateDiscount(applyDiscount: boolean) {
this.applyDiscount = applyDiscount;
await this.save();
},
async deleteJob(jobId: string) {
const jobIndex = this.jobs.findIndex((e) => e.id == jobId);
if (jobIndex !== -1) {
this.jobs.splice(jobIndex, 1);
await this.save();
}
},
async setSelectedOffer(jobId: string, offer: OffersRecord | null, price: string | null, tierName?: string | null, tierTitle?: string | null, tierBaseHours?: number | null) {
const job = this.jobs.find((e) => e.id === jobId);
if (job) {
job.selectedOffer = offer || {} as OffersRecord;
job.selectedPrice = price || '';
if (tierName) {
job.selectedTierName = tierName;
} else if (tierName === null) {
job.selectedTierName = undefined;
}
if (tierTitle) {
job.selectedTierTitle = tierTitle;
} else if (tierTitle === null) {
job.selectedTierTitle = undefined;
}
// Update baseHours to the selected tier's hours (replacing the initial band-aid hours)
if (tierBaseHours !== undefined && tierBaseHours !== null) {
job.baseHours = tierBaseHours;
}
// Mark this job's step as complete in customerProgress if an offer is selected
if (offer && price) {
// Sort jobs by price (highest first) to match the step order
const sortedJobs = [...this.jobs].sort((a, b) => {
const getPriceA = a.selectedPrice ? parseFloat(a.selectedPrice) : ((a.baseHours || 0) + (a.extraTime || 0));
const getPriceB = b.selectedPrice ? parseFloat(b.selectedPrice) : ((b.baseHours || 0) + (b.extraTime || 0));
return getPriceB - getPriceA;
});
// Find the index of this job in the sorted array
const stepIndex = sortedJobs.findIndex((j) => j.id === jobId);
if (stepIndex >= 0 && stepIndex < this.customerProgress.length) {
this.customerProgress[stepIndex] = true;
}
}
await this.save();
}
},
async startSession() {
this.startTime = new Date().toISOString();
this.endTime = "";
await this.save();
},
async endSession() {
this.endTime = new Date().toISOString();
await this.save();
// Reset to a new session
this.sessionId = 0;
this.startTime = "";
this.endTime = "";
this.activeJob = "";
this.jobs = [];
this.hasHadRealJobs = false;
await this.save();
},
// Search-related actions
async loadProblemsAndTags() {
await this.initDb();
if (this.db) {
this.problems = (await this.db.selectAll("problems")) || [];
this.problemTags = (await this.db.selectAll("problemTags")) || [];
// Append tag names to each problem's description for easier searching
this.problems.forEach((problem) => {
const tagIds = this.getProblemTagIds(problem);
const tagNames = tagIds
.map((tagId) => this.problemTags.find((tag) => tag.id === tagId))
.filter((tag) => tag !== undefined)
.map((tag) => tag!.name!)
.filter((name) => name !== undefined);
if (tagNames.length > 0) {
const tagText = tagNames.map(tag => '#' + tag).join(" ");
problem.description = problem.description
? `${problem.description} ${tagText}`
: tagText;
}
});
await this.save();
}
},
getProblemTagIds(problem: ProblemsRecord): string[] {
if (!problem.problemTags) return [];
if (typeof problem.problemTags === "string") {
try {
return JSON.parse(problem.problemTags);
} catch (e) {
return [];
}
} else if (Array.isArray(problem.problemTags)) {
return problem.problemTags;
}
return [];
},
async selectTag(tagId: string) {
// Save current state to history before making changes
this.selectionHistory.push({
tags: [...this.selectedTags],
search: this.appliedSearchQuery
});
// Limit history to last 20 states
if (this.selectionHistory.length > 20) {
this.selectionHistory.shift();
}
const index = this.selectedTags.indexOf(tagId);
if (index > -1) {
this.selectedTags.splice(index, 1);
} else {
this.selectedTags.push(tagId);
}
await this.save();
},
async setSearchQuery(query: string) {
// Save current state to history before making changes
this.selectionHistory.push({
tags: [...this.selectedTags],
search: this.appliedSearchQuery
});
// Limit history to last 20 states
if (this.selectionHistory.length > 20) {
this.selectionHistory.shift();
}
// Append to existing search query if there is one
if (this.appliedSearchQuery.trim() !== "") {
this.appliedSearchQuery = this.appliedSearchQuery + " " + query;
} else {
this.appliedSearchQuery = query;
}
await this.save();
},
async removeSearchTerm(index: number) {
// Save current state to history before making changes
this.selectionHistory.push({
tags: [...this.selectedTags],
search: this.appliedSearchQuery
});
// Limit history to last 20 states
if (this.selectionHistory.length > 20) {
this.selectionHistory.shift();
}
const terms = this.appliedSearchQuery.split(/\s+/).filter(term => term.trim() !== "");
terms.splice(index, 1);
this.appliedSearchQuery = terms.join(" ");
await this.save();
},
async clearSearch() {
this.appliedSearchQuery = "";
this.selectedTags = [];
this.selectionHistory = [];
await this.save();
},
async undoSearch() {
if (this.selectionHistory.length === 0) return;
const previousState = this.selectionHistory.pop();
if (previousState) {
this.selectedTags = previousState.tags;
this.appliedSearchQuery = previousState.search;
await this.save();
}
},
// Theme-related actions
async updateThemeColor(colorName: keyof SessionThemeColors, hexValue: string) {
if (!this.themeColors) {
this.themeColors = {} as SessionThemeColors;
}
this.themeColors[colorName] = hexValue;
await this.save();
},
async setThemeColors(colors: SessionThemeColors) {
this.themeColors = { ...colors };
await this.save();
},
async resetTheme() {
this.themeColors = {} as SessionThemeColors;
await this.save();
},
// Menu theme-related actions
async updateMenuThemeColor(colorName: keyof MenuThemeColors, hexValue: string) {
if (!this.menuThemeColors) {
this.menuThemeColors = {} as MenuThemeColors;
}
this.menuThemeColors[colorName] = hexValue;
await this.save();
},
async setMenuThemeColors(colors: MenuThemeColors) {
this.menuThemeColors = { ...colors };
await this.save();
},
async resetMenuTheme() {
this.menuThemeColors = {} as MenuThemeColors;
await this.save();
},
// Payment method actions
async updatePaymentMethod(method: string) {
this.paymentMethod = method;
await this.save();
},
// Edit mode actions
async toggleEditMode() {
this.editMode = !this.editMode;
await this.save();
console.log('[SessionStore] Edit mode toggled:', this.editMode);
},
// Secret mode actions
async toggleSecretMode() {
this.secretMode = !this.secretMode;
await this.save();
console.log('[SessionStore] Secret mode toggled:', this.secretMode);
},
// Debug mode actions (in-memory only, not persisted)
toggleDebugMode() {
this.debugMode = !this.debugMode;
console.log('[SessionStore] Debug mode toggled:', this.debugMode);
},
// Customer progress actions
async initializeCustomerProgress() {
// Initialize customer progress array based on number of jobs
// N jobs + 2 additional steps (review + payment)
const totalSteps = this.jobs.length + 2;
this.customerProgress = new Array(totalSteps).fill(false);
await this.save();
},
async updateCustomerProgress(stepIndex: number, value: string | boolean) {
if (stepIndex >= 0 && stepIndex < this.customerProgress.length) {
this.customerProgress[stepIndex] = value;
await this.save();
}
},
},
});