diff --git a/src/db/schema.ts b/src/db/schema.ts index 269a820..0f3f627 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -46,6 +46,9 @@ export const projects = pgTable("projects", { name: text("name").notNull(), // brand/project name accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), archived: boolean("archived").notNull().default(false), + offer_id: text("offer_id") + .references(() => offer_micros.id, { onDelete: "set null" }), + created_from_lead_id: text("created_from_lead_id"), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -60,6 +63,8 @@ export const phases = pgTable("phases", { title: text("title").notNull(), sort_order: integer("sort_order").notNull().default(0), status: text("status").notNull().default("upcoming"), // upcoming | active | done + offer_phase_id: text("offer_phase_id") + .references(() => offer_phases.id, { onDelete: "set null" }), }); // ============ TASKS ============ @@ -202,12 +207,18 @@ export const quote_items = pgTable("quote_items", { project_id: text("project_id") .notNull() .references(() => projects.id, { onDelete: "cascade" }), + quote_id: text("quote_id") + .references(() => quotes.id, { onDelete: "cascade" }), service_id: text("service_id") .references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), custom_label: text("custom_label"), // free-form item label (when service_id is null) + offer_micro_id: text("offer_micro_id") + .references(() => offer_micros.id, { onDelete: "set null" }), + offer_phase_id: text("offer_phase_id") + .references(() => offer_phases.id, { onDelete: "set null" }), }); // ============ SETTINGS (global admin settings — key-value store) ============ @@ -289,6 +300,77 @@ export const project_offers = pgTable("project_offers", { created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +// ============ OFFER PHASES (hierarchical breakdown of offer micros) ============ +export const offer_phases = pgTable("offer_phases", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + micro_id: text("micro_id") + .notNull() + .references(() => offer_micros.id, { onDelete: "cascade" }), + title: text("title").notNull(), + description: text("description"), + sort_order: integer("sort_order").notNull().default(0), + duration_weeks: integer("duration_weeks"), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// ============ OFFER PHASE SERVICES (junction: offer_phases <-> services) ============ +export const offer_phase_services = pgTable( + "offer_phase_services", + { + phase_id: text("phase_id") + .notNull() + .references(() => offer_phases.id, { onDelete: "cascade" }), + service_id: text("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.phase_id, t.service_id] }), + }) +); + +// ============ QUOTES (separate header from line items) ============ +export const quotes = pgTable("quotes", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + lead_id: text("lead_id") + .references(() => leads.id, { onDelete: "cascade" }), + client_id: text("client_id") + .references(() => clients.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + offer_micro_id: text("offer_micro_id") + .references(() => offer_micros.id, { onDelete: "set null" }), + total_amount: numeric("total_amount", { precision: 10, scale: 2 }).notNull(), + status: text("status").notNull().default("draft"), + accepted_at: timestamp("accepted_at", { withTimezone: true }), + accepted_by_email: text("accepted_by_email"), + accepted_by_name: text("accepted_by_name"), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// ============ LEADS TABLE (CRM — Phase 10, but needed for quotes FK) ============ +// Placeholder table for Phase 10 CRM integration +export const leads = pgTable("leads", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + name: text("name").notNull(), + email: text("email"), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + // ============ RELATIONS ============ export const clientsRelations = relations(clients, ({ many }) => ({ @@ -297,6 +379,7 @@ export const clientsRelations = relations(clients, ({ many }) => ({ export const projectsRelations = relations(projects, ({ one, many }) => ({ client: one(clients, { fields: [projects.client_id], references: [clients.id] }), + offer: one(offer_micros, { fields: [projects.offer_id], references: [offer_micros.id] }), phases: many(phases), payments: many(payments), documents: many(documents), @@ -308,6 +391,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({ export const phasesRelations = relations(phases, ({ one, many }) => ({ project: one(projects, { fields: [phases.project_id], references: [projects.id] }), + offerPhase: one(offer_phases, { fields: [phases.offer_phase_id], references: [offer_phases.id] }), tasks: many(tasks), })); @@ -351,10 +435,22 @@ export const quoteItemsRelations = relations(quote_items, ({ one }) => ({ fields: [quote_items.project_id], references: [projects.id], }), + quote: one(quotes, { + fields: [quote_items.quote_id], + references: [quotes.id], + }), service: one(service_catalog, { fields: [quote_items.service_id], references: [service_catalog.id], }), + offerMicro: one(offer_micros, { + fields: [quote_items.offer_micro_id], + references: [offer_micros.id], + }), + offerPhase: one(offer_phases, { + fields: [quote_items.offer_phase_id], + references: [offer_phases.id], + }), })); export const serviceCatalogRelations = relations( @@ -388,6 +484,27 @@ export const projectOffersRelations = relations(project_offers, ({ one }) => ({ micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }), })); +export const offerPhasesRelations = relations(offer_phases, ({ one, many }) => ({ + micro: one(offer_micros, { fields: [offer_phases.micro_id], references: [offer_micros.id] }), + services: many(offer_phase_services), +})); + +export const offerPhaseServicesRelations = relations(offer_phase_services, ({ one }) => ({ + phase: one(offer_phases, { fields: [offer_phase_services.phase_id], references: [offer_phases.id] }), + service: one(services, { fields: [offer_phase_services.service_id], references: [services.id] }), +})); + +export const leadsRelations = relations(leads, ({ many }) => ({ + quotes: many(quotes), +})); + +export const quotesRelations = relations(quotes, ({ one, many }) => ({ + lead: one(leads, { fields: [quotes.lead_id], references: [leads.id] }), + client: one(clients, { fields: [quotes.client_id], references: [clients.id] }), + micro: one(offer_micros, { fields: [quotes.offer_micro_id], references: [offer_micros.id] }), + items: many(quote_items), +})); + // ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============ export type Client = typeof clients.$inferSelect; @@ -427,4 +544,12 @@ export type NewOfferService = typeof offer_services.$inferInsert; export type OfferMicroService = typeof offer_micro_services.$inferSelect; export type NewOfferMicroService = typeof offer_micro_services.$inferInsert; export type ProjectOffer = typeof project_offers.$inferSelect; -export type NewProjectOffer = typeof project_offers.$inferInsert; \ No newline at end of file +export type NewProjectOffer = typeof project_offers.$inferInsert; +export type OfferPhase = typeof offer_phases.$inferSelect; +export type NewOfferPhase = typeof offer_phases.$inferInsert; +export type OfferPhaseService = typeof offer_phase_services.$inferSelect; +export type NewOfferPhaseService = typeof offer_phase_services.$inferInsert; +export type Quote = typeof quotes.$inferSelect; +export type NewQuote = typeof quotes.$inferInsert; +export type Lead = typeof leads.$inferSelect; +export type NewLead = typeof leads.$inferInsert; \ No newline at end of file diff --git a/src/types/offer.ts b/src/types/offer.ts new file mode 100644 index 0000000..925c188 --- /dev/null +++ b/src/types/offer.ts @@ -0,0 +1,11 @@ +// Export offer-related types from schema for use in components and API routes +export type { + OfferPhase, + NewOfferPhase, + OfferPhaseService, + NewOfferPhaseService, + Quote, + NewQuote, + Lead, + NewLead, +} from "@/db/schema";