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:
+126
-1
@@ -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;
|
||||
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;
|
||||
Reference in New Issue
Block a user