diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a68c1799..46368d7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ follow semantic versioning; release dates are ISO 8601. ## v2.2.3 — Planned +### Public API + +- **A structured proposal document model.** The proposal family's data layer knew one + shape — a titled run of prose sections with a flat timeline and pricing list — which + cannot carry the structured business proposal: brand marks, an authored multi-line + title, an at-a-glance fact card, goal cells, a numbered scope list, authored + deliverable columns, a phase grid with its own headers, priced rows with a + `Role` (`NONE` / `SUBTOTAL` / `OPTIONAL`) and a total band, and a signing card. + `templates.data.proposal` now carries that second model — + `StructuredProposalData` (+ its section records) wrapped by + `StructuredProposalDocumentSpec` — alongside the narrative one; a preset consumes + the model whose shape it renders. Every component normalizes `null` to its empty + form and freezes its collections, matching the family's existing records. + ### Templates - **Monogram Sidebar draws the employer.** Its experience entries rendered the position, diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalAcceptance.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalAcceptance.java new file mode 100644 index 000000000..5c24f4220 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalAcceptance.java @@ -0,0 +1,31 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The signing card that closes a structured proposal: an icon-badged + * heading, the acceptance statement, and the labelled signature fields. + * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param statement the acceptance statement paragraph + * @param fields the signature field labels, in order (e.g. name, date, + * signature) + */ +public record ProposalAcceptance( + String heading, + String icon, + String statement, + List fields) { + + /** + * Normalizes optional fields and freezes the field list. + */ + public ProposalAcceptance { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + statement = Objects.requireNonNullElse(statement, ""); + fields = List.copyOf(Objects.requireNonNullElse(fields, List.of())); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalBrand.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalBrand.java new file mode 100644 index 000000000..ebec53e4f --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalBrand.java @@ -0,0 +1,39 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.Objects; + +/** + * The sender's brand marks for a structured proposal: the logo monogram, + * the two wordmark lines, the tracked document label, and the footer line. + * + *

These are display strings, not identity data — the narrative model's + * {@link ProposalParty} carries the sender's address and contact details; + * this record carries only what the page chrome draws.

+ * + * @param monogram the one- or two-letter logo mark + * @param nameLine1 first wordmark line + * @param nameLine2 second wordmark line + * @param documentLabel the document label, set in tracked capitals + * @param website the website line shown in the footer + * @param footerName the brand name shown in the footer + */ +public record ProposalBrand( + String monogram, + String nameLine1, + String nameLine2, + String documentLabel, + String website, + String footerName) { + + /** + * Normalizes optional fields to empty strings. + */ + public ProposalBrand { + monogram = Objects.requireNonNullElse(monogram, ""); + nameLine1 = Objects.requireNonNullElse(nameLine1, ""); + nameLine2 = Objects.requireNonNullElse(nameLine2, ""); + documentLabel = Objects.requireNonNullElse(documentLabel, ""); + website = Objects.requireNonNullElse(website, ""); + footerName = Objects.requireNonNullElse(footerName, ""); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalDeliverables.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalDeliverables.java new file mode 100644 index 000000000..e492d92ce --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalDeliverables.java @@ -0,0 +1,34 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The deliverables band of a structured proposal: an icon-badged heading and + * two authored bullet columns. + * + *

The left/right split is authored rather than computed so it can be + * tuned per proposal — moving one long bullet between columns is a data + * revision, not a layout change.

+ * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param leftColumn the bullets of the left column, in order + * @param rightColumn the bullets of the right column, in order + */ +public record ProposalDeliverables( + String heading, + String icon, + List leftColumn, + List rightColumn) { + + /** + * Normalizes optional fields and freezes both columns. + */ + public ProposalDeliverables { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + leftColumn = List.copyOf(Objects.requireNonNullElse(leftColumn, List.of())); + rightColumn = List.copyOf(Objects.requireNonNullElse(rightColumn, List.of())); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGlance.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGlance.java new file mode 100644 index 000000000..2d98f9741 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGlance.java @@ -0,0 +1,43 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The at-a-glance fact card of a structured proposal. + * + * @param heading the card heading + * @param facts the fact rows, in order + */ +public record ProposalGlance(String heading, List facts) { + + /** + * Normalizes optional fields and freezes the fact list. + */ + public ProposalGlance { + heading = Objects.requireNonNullElse(heading, ""); + facts = List.copyOf(Objects.requireNonNullElse(facts, List.of())); + } + + /** + * One fact row: an icon, a quiet label, the value line, and an optional + * parenthetical note. + * + * @param icon the icon token of the fact + * @param label the quiet label above the value + * @param value the emphasized value line + * @param note the optional second value line; empty when the row has none + */ + public record Fact(String icon, String label, String value, String note) { + + /** + * Normalizes optional fields to empty strings. + */ + public Fact { + icon = Objects.requireNonNullElse(icon, ""); + label = Objects.requireNonNullElse(label, ""); + value = Objects.requireNonNullElse(value, ""); + note = Objects.requireNonNullElse(note, ""); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGoals.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGoals.java new file mode 100644 index 000000000..f4513d142 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalGoals.java @@ -0,0 +1,41 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The project-goals band of a structured proposal: an icon-badged heading + * and a horizontal run of icon-beside-text goal cells. + * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param items the goal cells, in order + */ +public record ProposalGoals(String heading, String icon, List items) { + + /** + * Normalizes optional fields and freezes the goal list. + */ + public ProposalGoals { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + items = List.copyOf(Objects.requireNonNullElse(items, List.of())); + } + + /** + * One goal cell. + * + * @param icon the icon token of the goal + * @param text the goal statement + */ + public record Goal(String icon, String text) { + + /** + * Normalizes optional fields to empty strings. + */ + public Goal { + icon = Objects.requireNonNullElse(icon, ""); + text = Objects.requireNonNullElse(text, ""); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalInvestment.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalInvestment.java new file mode 100644 index 000000000..5df8593a9 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalInvestment.java @@ -0,0 +1,78 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The priced block of a structured proposal: an icon-badged heading, the + * authored table headers, the priced rows with their roles, and the total + * band. + * + *

Distinct from the narrative model's {@link ProposalPricingRow}: a row + * here names its role through {@link Role} — presets are expected to set + * subtotal and optional rows apart — and the grand total is its own + * labelled band rather than the last row of the list.

+ * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param itemHeader the header label of the item column + * @param amountHeader the header label of the amount column + * @param rows the priced rows, in order + * @param totalLabel the label of the total band + * @param totalAmount the amount of the total band + */ +public record ProposalInvestment( + String heading, + String icon, + String itemHeader, + String amountHeader, + List rows, + String totalLabel, + String totalAmount) { + + /** + * Normalizes optional fields and freezes the row list. + */ + public ProposalInvestment { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + itemHeader = Objects.requireNonNullElse(itemHeader, ""); + amountHeader = Objects.requireNonNullElse(amountHeader, ""); + rows = List.copyOf(Objects.requireNonNullElse(rows, List.of())); + totalLabel = Objects.requireNonNullElse(totalLabel, ""); + totalAmount = Objects.requireNonNullElse(totalAmount, ""); + } + + /** + * One priced row. + * + * @param label the row label + * @param amount the row amount text + * @param role the row's role; {@code null} normalizes to + * {@link Role#NONE} + */ + public record Row(String label, String amount, Role role) { + + /** + * Normalizes optional fields. + */ + public Row { + label = Objects.requireNonNullElse(label, ""); + amount = Objects.requireNonNullElse(amount, ""); + role = Objects.requireNonNullElse(role, Role.NONE); + } + } + + /** + * The role of a priced row — named for what the row is, not for how it + * looks, so a theme change moves the styling without touching the data. + */ + public enum Role { + /** An ordinary priced row. */ + NONE, + /** A subtotal row; presets are expected to set it apart. */ + SUBTOTAL, + /** An optional line item; presets are expected to mark its amount. */ + OPTIONAL + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalMetaLine.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalMetaLine.java new file mode 100644 index 000000000..307550619 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalMetaLine.java @@ -0,0 +1,23 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.Objects; + +/** + * The prepared-for / prepared-by / date line under a structured proposal's + * title. + * + * @param preparedFor the client the proposal is prepared for + * @param preparedBy the sender the proposal is prepared by + * @param date the proposal date text + */ +public record ProposalMetaLine(String preparedFor, String preparedBy, String date) { + + /** + * Normalizes optional fields to empty strings. + */ + public ProposalMetaLine { + preparedFor = Objects.requireNonNullElse(preparedFor, ""); + preparedBy = Objects.requireNonNullElse(preparedBy, ""); + date = Objects.requireNonNullElse(date, ""); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalPhaseGrid.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalPhaseGrid.java new file mode 100644 index 000000000..2ba78d402 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalPhaseGrid.java @@ -0,0 +1,70 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The phase grid of a structured proposal: an icon-badged heading, the + * authored column headers, and one row per phase. + * + *

Distinct from the narrative model's {@link ProposalTimelineItem}: a + * phase carries a number, a focus column and an output column, and the grid + * owns its header labels — the narrative timeline is a phase/duration/details + * triple with preset-owned headers.

+ * + *

Unlike {@link ProposalInvestment}, whose two columns are fixed by the + * concept and therefore named fields, the phase grid's column set belongs + * to the preset that draws it — so the headers stay an authored list in + * column order. A preset validates that the list carries one label per + * column it renders; the data layer does not fix the count.

+ * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param columnHeaders the grid's header labels, one per rendered column, + * in column order + * @param phases the phase rows, in order + */ +public record ProposalPhaseGrid( + String heading, + String icon, + List columnHeaders, + List phases) { + + /** + * Normalizes optional fields and freezes both lists. + */ + public ProposalPhaseGrid { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + columnHeaders = List.copyOf(Objects.requireNonNullElse(columnHeaders, List.of())); + phases = List.copyOf(Objects.requireNonNullElse(phases, List.of())); + } + + /** + * One phase row of the grid. + * + * @param number the phase number, as authored (e.g. {@code "1"}) + * @param name the phase name + * @param focus what the phase concentrates on + * @param duration the phase duration text + * @param output what the phase delivers + */ + public record Phase( + String number, + String name, + String focus, + String duration, + String output) { + + /** + * Normalizes optional fields to empty strings. + */ + public Phase { + number = Objects.requireNonNullElse(number, ""); + name = Objects.requireNonNullElse(name, ""); + focus = Objects.requireNonNullElse(focus, ""); + duration = Objects.requireNonNullElse(duration, ""); + output = Objects.requireNonNullElse(output, ""); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalScope.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalScope.java new file mode 100644 index 000000000..7b828bfd1 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalScope.java @@ -0,0 +1,46 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The numbered scope-of-work list of a structured proposal. + * + *

Distinct from the narrative model's {@link ProposalSection}: a scope + * item is a numbered title-plus-description row, not a titled run of + * paragraphs.

+ * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param items the numbered scope rows, in order + */ +public record ProposalScope(String heading, String icon, List items) { + + /** + * Normalizes optional fields and freezes the item list. + */ + public ProposalScope { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + items = List.copyOf(Objects.requireNonNullElse(items, List.of())); + } + + /** + * One numbered scope row. + * + * @param number the step number, as authored (e.g. {@code "01"}) + * @param title the step title + * @param description the step description + */ + public record Item(String number, String title, String description) { + + /** + * Normalizes optional fields to empty strings. + */ + public Item { + number = Objects.requireNonNullElse(number, ""); + title = Objects.requireNonNullElse(title, ""); + description = Objects.requireNonNullElse(description, ""); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalSummaryBlock.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalSummaryBlock.java new file mode 100644 index 000000000..0a713576d --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalSummaryBlock.java @@ -0,0 +1,28 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The executive-summary block of a structured proposal: an icon-badged + * heading and its body paragraphs. + * + *

The narrative model's {@link ProposalData#executiveSummary()} is one + * paragraph with a preset-owned heading; this block owns its heading, its + * icon token, and as many paragraphs as the author wrote.

+ * + * @param heading the section heading, as authored + * @param icon the icon token of the section badge + * @param paragraphs the summary paragraphs, in order + */ +public record ProposalSummaryBlock(String heading, String icon, List paragraphs) { + + /** + * Normalizes optional fields and freezes the paragraph list. + */ + public ProposalSummaryBlock { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + paragraphs = List.copyOf(Objects.requireNonNullElse(paragraphs, List.of())); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTermsBlock.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTermsBlock.java new file mode 100644 index 000000000..28bcfaa92 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTermsBlock.java @@ -0,0 +1,27 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.List; +import java.util.Objects; + +/** + * The bulleted terms block of a structured proposal. + * + *

Distinct from the narrative model's {@link ProposalData#acceptanceTerms()}: + * this block owns its heading and icon and is its own section, not part of + * the acceptance card.

+ * + * @param heading the section heading + * @param icon the icon token of the section badge + * @param items the term bullets, in order + */ +public record ProposalTermsBlock(String heading, String icon, List items) { + + /** + * Normalizes optional fields and freezes the item list. + */ + public ProposalTermsBlock { + heading = Objects.requireNonNullElse(heading, ""); + icon = Objects.requireNonNullElse(icon, ""); + items = List.copyOf(Objects.requireNonNullElse(items, List.of())); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTitleLines.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTitleLines.java new file mode 100644 index 000000000..6514d732d --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/ProposalTitleLines.java @@ -0,0 +1,27 @@ +package com.demcha.compose.document.templates.data.proposal; + +import java.util.Objects; + +/** + * The display title of a structured proposal, as up to three authored lines. + * + *

The lines are separate fields rather than one string with newlines + * because presets are expected to set the lead line apart from the other + * two typographically — the split carries the emphasis and is part of the + * content, not of the layout.

+ * + * @param lead the first title line + * @param second the second title line + * @param third the third title line + */ +public record ProposalTitleLines(String lead, String second, String third) { + + /** + * Normalizes optional lines to empty strings. + */ + public ProposalTitleLines { + lead = Objects.requireNonNullElse(lead, ""); + second = Objects.requireNonNullElse(second, ""); + third = Objects.requireNonNullElse(third, ""); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalData.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalData.java new file mode 100644 index 000000000..e1847c45c --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalData.java @@ -0,0 +1,250 @@ +package com.demcha.compose.document.templates.data.proposal; + +/** + * Display-oriented input for structured proposal documents. + * + *

The narrative model ({@link ProposalData}) is a titled run of prose + * sections; this model is the structured business proposal: brand marks, an + * authored multi-line title, an at-a-glance fact card, goal cells, a + * numbered scope list, two deliverable columns, a phase grid, a priced + * investment block with row roles, a terms block, and a signing card. The + * sections own their headings and icon tokens (the glance card carries a + * heading only — its icons live on the individual facts), so the section + * wording is content, not a preset choice.

+ * + *

Both models stay: a preset consumes the one whose shape it renders. + * Every component normalizes {@code null} to its empty form, so a partial + * document composes without null checks in preset code. The section records + * construct positionally; this builder is where a document is assembled.

+ * + * @param brand the sender's brand marks + * @param title the authored title lines + * @param meta the prepared-for / prepared-by / date line + * @param executiveSummary the executive-summary block + * @param glance the at-a-glance fact card + * @param goals the project-goals band + * @param scope the numbered scope-of-work list + * @param deliverables the two-column deliverables band + * @param timeline the phase grid (the structured counterpart of the + * narrative model's {@code timeline()}) + * @param investment the priced block + * @param terms the bulleted terms block + * @param acceptance the signing card + */ +public record StructuredProposalData( + ProposalBrand brand, + ProposalTitleLines title, + ProposalMetaLine meta, + ProposalSummaryBlock executiveSummary, + ProposalGlance glance, + ProposalGoals goals, + ProposalScope scope, + ProposalDeliverables deliverables, + ProposalPhaseGrid timeline, + ProposalInvestment investment, + ProposalTermsBlock terms, + ProposalAcceptance acceptance) { + + /** + * Normalizes absent components to their empty forms. + */ + public StructuredProposalData { + brand = brand == null + ? new ProposalBrand(null, null, null, null, null, null) : brand; + title = title == null + ? new ProposalTitleLines(null, null, null) : title; + meta = meta == null + ? new ProposalMetaLine(null, null, null) : meta; + executiveSummary = executiveSummary == null + ? new ProposalSummaryBlock(null, null, null) : executiveSummary; + glance = glance == null + ? new ProposalGlance(null, null) : glance; + goals = goals == null + ? new ProposalGoals(null, null, null) : goals; + scope = scope == null + ? new ProposalScope(null, null, null) : scope; + deliverables = deliverables == null + ? new ProposalDeliverables(null, null, null, null) : deliverables; + timeline = timeline == null + ? new ProposalPhaseGrid(null, null, null, null) : timeline; + investment = investment == null + ? new ProposalInvestment(null, null, null, null, null, null, null) : investment; + terms = terms == null + ? new ProposalTermsBlock(null, null, null) : terms; + acceptance = acceptance == null + ? new ProposalAcceptance(null, null, null, null) : acceptance; + } + + /** + * Starts a fluent structured proposal data builder. + * + * @return structured proposal data builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Fluent builder for complete structured proposal content. + */ + public static final class Builder { + private ProposalBrand brand; + private ProposalTitleLines title; + private ProposalMetaLine meta; + private ProposalSummaryBlock executiveSummary; + private ProposalGlance glance; + private ProposalGoals goals; + private ProposalScope scope; + private ProposalDeliverables deliverables; + private ProposalPhaseGrid timeline; + private ProposalInvestment investment; + private ProposalTermsBlock terms; + private ProposalAcceptance acceptance; + + private Builder() { + } + + /** + * Sets the sender's brand marks. + * + * @param brand brand marks + * @return this builder + */ + public Builder brand(ProposalBrand brand) { + this.brand = brand; + return this; + } + + /** + * Sets the authored title lines. + * + * @param title title lines + * @return this builder + */ + public Builder title(ProposalTitleLines title) { + this.title = title; + return this; + } + + /** + * Sets the prepared-for / prepared-by / date line. + * + * @param meta meta line + * @return this builder + */ + public Builder meta(ProposalMetaLine meta) { + this.meta = meta; + return this; + } + + /** + * Sets the executive-summary block. + * + * @param executiveSummary summary block + * @return this builder + */ + public Builder executiveSummary(ProposalSummaryBlock executiveSummary) { + this.executiveSummary = executiveSummary; + return this; + } + + /** + * Sets the at-a-glance fact card. + * + * @param glance fact card + * @return this builder + */ + public Builder glance(ProposalGlance glance) { + this.glance = glance; + return this; + } + + /** + * Sets the project-goals band. + * + * @param goals goals band + * @return this builder + */ + public Builder goals(ProposalGoals goals) { + this.goals = goals; + return this; + } + + /** + * Sets the numbered scope-of-work list. + * + * @param scope scope list + * @return this builder + */ + public Builder scope(ProposalScope scope) { + this.scope = scope; + return this; + } + + /** + * Sets the two-column deliverables band. + * + * @param deliverables deliverables band + * @return this builder + */ + public Builder deliverables(ProposalDeliverables deliverables) { + this.deliverables = deliverables; + return this; + } + + /** + * Sets the phase grid. + * + * @param timeline phase grid + * @return this builder + */ + public Builder timeline(ProposalPhaseGrid timeline) { + this.timeline = timeline; + return this; + } + + /** + * Sets the priced block. + * + * @param investment priced block + * @return this builder + */ + public Builder investment(ProposalInvestment investment) { + this.investment = investment; + return this; + } + + /** + * Sets the bulleted terms block. + * + * @param terms terms block + * @return this builder + */ + public Builder terms(ProposalTermsBlock terms) { + this.terms = terms; + return this; + } + + /** + * Sets the signing card. + * + * @param acceptance signing card + * @return this builder + */ + public Builder acceptance(ProposalAcceptance acceptance) { + this.acceptance = acceptance; + return this; + } + + /** + * Builds the normalized structured proposal data. + * + * @return structured proposal data + */ + public StructuredProposalData build() { + return new StructuredProposalData(brand, title, meta, executiveSummary, + glance, goals, scope, deliverables, timeline, investment, + terms, acceptance); + } + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDocumentSpec.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDocumentSpec.java new file mode 100644 index 000000000..72384d5c2 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDocumentSpec.java @@ -0,0 +1,34 @@ +package com.demcha.compose.document.templates.data.proposal; + +/** + * Public compose-first input for structured proposal templates. + * + *

Authoring role: the document-level object structured proposal + * presets are parameterised on, the way narrative presets are parameterised + * on {@link ProposalDocumentSpec}. Presets that render the structured shape + * — brand marks, fact card, goal cells, phase grid, priced block, signing + * card — consume this spec; presets that render prose sections stay on the + * narrative one.

+ * + * @param proposal normalized structured proposal content + */ +public record StructuredProposalDocumentSpec(StructuredProposalData proposal) { + + /** + * Creates a normalized structured proposal document spec. + */ + public StructuredProposalDocumentSpec { + proposal = proposal == null ? StructuredProposalData.builder().build() : proposal; + } + + /** + * Wraps existing structured proposal data in the document-level spec + * expected by structured proposal templates. + * + * @param proposal structured proposal data + * @return document spec + */ + public static StructuredProposalDocumentSpec from(StructuredProposalData proposal) { + return new StructuredProposalDocumentSpec(proposal); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java index 81af11751..62ae78a5d 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java @@ -1,5 +1,15 @@ /** * Shared, render-neutral proposal document specs and supporting data * records, consumed by the layered {@code proposal.presets} presets. + * + *

Two document models live here, and a preset consumes the one whose + * shape it renders: the narrative model + * ({@link com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec} + * — a titled run of prose sections with a flat timeline and pricing list) + * and the structured model + * ({@link com.demcha.compose.document.templates.data.proposal.StructuredProposalDocumentSpec} + * — brand marks, fact card, goal cells, numbered scope, phase grid, priced + * block with row roles, and a signing card, the sections owning their + * headings and icon tokens).

*/ package com.demcha.compose.document.templates.data.proposal; diff --git a/templates/src/test/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDataTest.java b/templates/src/test/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDataTest.java new file mode 100644 index 000000000..3d2c96ac1 --- /dev/null +++ b/templates/src/test/java/com/demcha/compose/document/templates/data/proposal/StructuredProposalDataTest.java @@ -0,0 +1,198 @@ +package com.demcha.compose.document.templates.data.proposal; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Normalization and immutability contract of the structured proposal model: + * absent components become their empty forms, leaf strings never surface as + * {@code null}, row roles default to {@code NONE}, and every collection is + * frozen at construction. + */ +class StructuredProposalDataTest { + + @Test + void emptyBuilderYieldsEmptyFormsForEveryComponent() { + StructuredProposalData data = StructuredProposalData.builder().build(); + + assertThat(data.brand().monogram()).isEmpty(); + assertThat(data.title().lead()).isEmpty(); + assertThat(data.meta().preparedFor()).isEmpty(); + assertThat(data.executiveSummary().paragraphs()).isEmpty(); + assertThat(data.glance().facts()).isEmpty(); + assertThat(data.goals().items()).isEmpty(); + assertThat(data.scope().items()).isEmpty(); + assertThat(data.deliverables().leftColumn()).isEmpty(); + assertThat(data.deliverables().rightColumn()).isEmpty(); + assertThat(data.timeline().columnHeaders()).isEmpty(); + assertThat(data.timeline().phases()).isEmpty(); + assertThat(data.investment().rows()).isEmpty(); + assertThat(data.terms().items()).isEmpty(); + assertThat(data.acceptance().fields()).isEmpty(); + } + + @Test + void leafRecordsNormalizeNullStringsToEmpty() { + ProposalGlance.Fact fact = new ProposalGlance.Fact(null, null, null, null); + assertThat(fact.icon()).isEmpty(); + assertThat(fact.label()).isEmpty(); + assertThat(fact.value()).isEmpty(); + assertThat(fact.note()).isEmpty(); + + ProposalGoals.Goal goal = new ProposalGoals.Goal(null, null); + assertThat(goal.icon()).isEmpty(); + assertThat(goal.text()).isEmpty(); + + ProposalScope.Item item = new ProposalScope.Item(null, null, null); + assertThat(item.number()).isEmpty(); + assertThat(item.title()).isEmpty(); + assertThat(item.description()).isEmpty(); + + ProposalPhaseGrid.Phase phase = new ProposalPhaseGrid.Phase(null, null, null, null, null); + assertThat(phase.number()).isEmpty(); + assertThat(phase.name()).isEmpty(); + assertThat(phase.focus()).isEmpty(); + assertThat(phase.duration()).isEmpty(); + assertThat(phase.output()).isEmpty(); + + ProposalInvestment.Row row = new ProposalInvestment.Row(null, null, null); + assertThat(row.label()).isEmpty(); + assertThat(row.amount()).isEmpty(); + + ProposalMetaLine meta = new ProposalMetaLine(null, null, null); + assertThat(meta.preparedFor()).isEmpty(); + assertThat(meta.preparedBy()).isEmpty(); + assertThat(meta.date()).isEmpty(); + + ProposalTitleLines title = new ProposalTitleLines(null, null, null); + assertThat(title.lead()).isEmpty(); + assertThat(title.second()).isEmpty(); + assertThat(title.third()).isEmpty(); + } + + @Test + void sectionHeadingsAndIconsNormalizeNullToEmpty() { + assertThat(new ProposalSummaryBlock(null, null, null).heading()).isEmpty(); + assertThat(new ProposalSummaryBlock(null, null, null).icon()).isEmpty(); + assertThat(new ProposalGlance(null, null).heading()).isEmpty(); + assertThat(new ProposalGoals(null, null, null).icon()).isEmpty(); + assertThat(new ProposalScope(null, null, null).heading()).isEmpty(); + assertThat(new ProposalDeliverables(null, null, null, null).icon()).isEmpty(); + assertThat(new ProposalPhaseGrid(null, null, null, null).heading()).isEmpty(); + assertThat(new ProposalInvestment(null, null, null, null, null, null, null) + .amountHeader()).isEmpty(); + assertThat(new ProposalTermsBlock(null, null, null).icon()).isEmpty(); + assertThat(new ProposalAcceptance(null, null, null, null).statement()).isEmpty(); + assertThat(new ProposalBrand(null, null, null, null, null, null).footerName()).isEmpty(); + } + + @Test + void investmentRowRoleDefaultsToNone() { + ProposalInvestment.Row row = new ProposalInvestment.Row("Design", "$1,000", null); + assertThat(row.role()).isEqualTo(ProposalInvestment.Role.NONE); + } + + @Test + void everyCollectionIsFrozenAtConstruction() { + List source = new ArrayList<>(List.of("seed")); + ProposalDeliverables deliverables = + new ProposalDeliverables("DELIVERABLES", "badge-deliverables", source, source); + source.add("added after construction"); + assertThat(deliverables.leftColumn()).containsExactly("seed"); + assertThat(deliverables.rightColumn()).containsExactly("seed"); + + List> frozen = List.of( + deliverables.leftColumn(), + deliverables.rightColumn(), + new ProposalSummaryBlock("S", "i", new ArrayList<>()).paragraphs(), + new ProposalGlance("G", new ArrayList<>()).facts(), + new ProposalGoals("G", "i", new ArrayList<>()).items(), + new ProposalScope("S", "i", new ArrayList<>()).items(), + new ProposalPhaseGrid("T", "i", new ArrayList<>(), new ArrayList<>()).columnHeaders(), + new ProposalPhaseGrid("T", "i", new ArrayList<>(), new ArrayList<>()).phases(), + new ProposalInvestment("I", "i", "ITEM", "AMOUNT", + new ArrayList<>(), "TOTAL", "$0").rows(), + new ProposalTermsBlock("T", "i", new ArrayList<>()).items(), + new ProposalAcceptance("A", "i", "s", new ArrayList<>()).fields()); + for (List list : frozen) { + assertThatThrownBy(() -> list.remove(0)) + .isInstanceOf(UnsupportedOperationException.class); + } + } + + @Test + void builderPassesEveryComponentThroughUnchanged() { + ProposalBrand brand = new ProposalBrand("N", "Northline", "Digital Studio", + "Proposal", "northline.example", "Northline Digital"); + ProposalTitleLines title = new ProposalTitleLines("A proposal", "for the", "redesign"); + ProposalMetaLine meta = new ProposalMetaLine("Client", "Studio", "2026-08-30"); + ProposalSummaryBlock summary = new ProposalSummaryBlock("SUMMARY", "badge-summary", + List.of("First paragraph.")); + ProposalGlance glance = new ProposalGlance("AT A GLANCE", + List.of(new ProposalGlance.Fact("fact-start", "START", "Sep 2026", ""))); + ProposalGoals goals = new ProposalGoals("GOALS", "badge-goals", + List.of(new ProposalGoals.Goal("goal-brand", "Sharpen the brand"))); + ProposalScope scope = new ProposalScope("SCOPE", "badge-scope", + List.of(new ProposalScope.Item("01", "Discovery", "Interviews and audit"))); + ProposalDeliverables deliverables = new ProposalDeliverables("DELIVERABLES", + "badge-deliverables", List.of("Style guide"), List.of("Launch support")); + ProposalPhaseGrid timeline = new ProposalPhaseGrid("TIMELINE", "badge-timeline", + List.of("PHASE", "FOCUS", "DURATION", "OUTPUT"), + List.of(new ProposalPhaseGrid.Phase("1", "Discovery", "Research", + "2 weeks", "Findings deck"))); + ProposalInvestment investment = new ProposalInvestment("INVESTMENT", + "badge-investment", "ITEM", "AMOUNT", + List.of(new ProposalInvestment.Row("Subtotal", "$9,000", + ProposalInvestment.Role.SUBTOTAL)), + "TOTAL", "$9,900"); + ProposalTermsBlock terms = new ProposalTermsBlock("TERMS", "badge-terms", + List.of("Net 14.")); + ProposalAcceptance acceptance = new ProposalAcceptance("ACCEPTANCE", + "badge-acceptance", "Signing below accepts this proposal.", + List.of("Name", "Date", "Signature")); + + StructuredProposalData data = StructuredProposalData.builder() + .brand(brand) + .title(title) + .meta(meta) + .executiveSummary(summary) + .glance(glance) + .goals(goals) + .scope(scope) + .deliverables(deliverables) + .timeline(timeline) + .investment(investment) + .terms(terms) + .acceptance(acceptance) + .build(); + + assertThat(data.brand()).isSameAs(brand); + assertThat(data.title()).isSameAs(title); + assertThat(data.meta()).isSameAs(meta); + assertThat(data.executiveSummary()).isSameAs(summary); + assertThat(data.glance()).isSameAs(glance); + assertThat(data.goals()).isSameAs(goals); + assertThat(data.scope()).isSameAs(scope); + assertThat(data.deliverables()).isSameAs(deliverables); + assertThat(data.timeline()).isSameAs(timeline); + assertThat(data.investment()).isSameAs(investment); + assertThat(data.terms()).isSameAs(terms); + assertThat(data.acceptance()).isSameAs(acceptance); + assertThat(data.investment().rows().get(0).role()) + .isEqualTo(ProposalInvestment.Role.SUBTOTAL); + } + + @Test + void documentSpecNormalizesNullProposalToEmptyData() { + assertThat(StructuredProposalDocumentSpec.from(null).proposal()).isNotNull(); + assertThat(new StructuredProposalDocumentSpec(null).proposal().glance().facts()).isEmpty(); + + StructuredProposalData data = StructuredProposalData.builder().build(); + assertThat(StructuredProposalDocumentSpec.from(data).proposal()).isSameAs(data); + } +}