Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
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();
      }
    },
  },
});