feat(08-01): add offer_phases, offer_phase_services, and quotes table schema

- Create offer_phases table (hierarchical phases within offer micros)
- Create offer_phase_services junction (phases → unified services)
- Create quotes table (separate headers from line items)
- Create leads placeholder table (FK for quotes, Phase 10 CRM)
- Update quote_items: add quote_id, offer_micro_id, offer_phase_id columns
- Update projects: add offer_id, created_from_lead_id columns
- Update phases: add offer_phase_id column for audit trail
- Add all Drizzle relations and TypeScript types
- Build verified with npm run build

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:14:56 +02:00
parent 645f33cdd4
commit 37fe5eae48
2 changed files with 137 additions and 1 deletions
+126 -1
View File
@@ -46,6 +46,9 @@ export const projects = pgTable("projects", {
name: text("name").notNull(), // brand/project name name: text("name").notNull(), // brand/project name
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
archived: boolean("archived").notNull().default(false), 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(), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
@@ -60,6 +63,8 @@ export const phases = pgTable("phases", {
title: text("title").notNull(), title: text("title").notNull(),
sort_order: integer("sort_order").notNull().default(0), sort_order: integer("sort_order").notNull().default(0),
status: text("status").notNull().default("upcoming"), // upcoming | active | done status: text("status").notNull().default("upcoming"), // upcoming | active | done
offer_phase_id: text("offer_phase_id")
.references(() => offer_phases.id, { onDelete: "set null" }),
}); });
// ============ TASKS ============ // ============ TASKS ============
@@ -202,12 +207,18 @@ export const quote_items = pgTable("quote_items", {
project_id: text("project_id") project_id: text("project_id")
.notNull() .notNull()
.references(() => projects.id, { onDelete: "cascade" }), .references(() => projects.id, { onDelete: "cascade" }),
quote_id: text("quote_id")
.references(() => quotes.id, { onDelete: "cascade" }),
service_id: text("service_id") service_id: text("service_id")
.references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref .references(() => service_catalog.id, { onDelete: "restrict" }), // nullable — free-form items have no catalog ref
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), // snapshot at time of quote
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
custom_label: text("custom_label"), // free-form item label (when service_id is null) 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) ============ // ============ 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(), 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 ============ // ============ RELATIONS ============
export const clientsRelations = relations(clients, ({ many }) => ({ export const clientsRelations = relations(clients, ({ many }) => ({
@@ -297,6 +379,7 @@ export const clientsRelations = relations(clients, ({ many }) => ({
export const projectsRelations = relations(projects, ({ one, many }) => ({ export const projectsRelations = relations(projects, ({ one, many }) => ({
client: one(clients, { fields: [projects.client_id], references: [clients.id] }), 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), phases: many(phases),
payments: many(payments), payments: many(payments),
documents: many(documents), documents: many(documents),
@@ -308,6 +391,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
export const phasesRelations = relations(phases, ({ one, many }) => ({ export const phasesRelations = relations(phases, ({ one, many }) => ({
project: one(projects, { fields: [phases.project_id], references: [projects.id] }), 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), tasks: many(tasks),
})); }));
@@ -351,10 +435,22 @@ export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
fields: [quote_items.project_id], fields: [quote_items.project_id],
references: [projects.id], references: [projects.id],
}), }),
quote: one(quotes, {
fields: [quote_items.quote_id],
references: [quotes.id],
}),
service: one(service_catalog, { service: one(service_catalog, {
fields: [quote_items.service_id], fields: [quote_items.service_id],
references: [service_catalog.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( 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] }), 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) ============ // ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============
export type Client = typeof clients.$inferSelect; 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 OfferMicroService = typeof offer_micro_services.$inferSelect;
export type NewOfferMicroService = typeof offer_micro_services.$inferInsert; export type NewOfferMicroService = typeof offer_micro_services.$inferInsert;
export type ProjectOffer = typeof project_offers.$inferSelect; export type ProjectOffer = typeof project_offers.$inferSelect;
export type NewProjectOffer = typeof project_offers.$inferInsert; 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;
+11
View File
@@ -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";