Seth commited on
Commit
de686dc
·
1 Parent(s): 400e193
server/__pycache__/form_schema.cpython-314.pyc CHANGED
Binary files a/server/__pycache__/form_schema.cpython-314.pyc and b/server/__pycache__/form_schema.cpython-314.pyc differ
 
server/__pycache__/pr_lines.cpython-314.pyc CHANGED
Binary files a/server/__pycache__/pr_lines.cpython-314.pyc and b/server/__pycache__/pr_lines.cpython-314.pyc differ
 
server/form_schema.py CHANGED
@@ -31,7 +31,8 @@ Return ONE JSON object only (no markdown). Shape:
31
  "id": "stable_snake_case_id",
32
  "label": "Full question text shown to the user (no Q1/Q2 prefixes)",
33
  "type": "select" | "chips" | "number",
34
- "options": ["required for select and chips: 3–12 distinct, short option strings"]
 
35
  }
36
  ]
37
  }
@@ -42,7 +43,8 @@ Rules:
42
  - **No open-ended typing:** NEVER use type "text" or "textarea". Users must tap choices only.
43
  - Prefer **select** with 5–12 concise options for objectives, scope, methodology, audience, timing, risk, quality level, etc.
44
  - Use **chips** for 3–8 mutually exclusive options when labels are short (single choice — same as select, shown as buttons).
45
- - Use **number** only for true numeric values (counts, currency amounts, percentages, sizes).
 
46
  - Every option string must be self-contained (no reliance on free-form explanations). If a case might need nuance, add an option such as "Other — see specification notes below".
47
  - Use stable `id` values (snake_case) — they are keys in saved data.
48
  - Same commodity must always get the same structure when regenerated; the app caches by commodity code, but ids and intent must stay consistent if you see similar commodities.
@@ -148,6 +150,11 @@ def _coerce_field_selectable(entry: dict[str, Any]) -> dict[str, Any]:
148
  elif typ == "number":
149
  out = {**entry, "type": "number"}
150
  out.pop("options", None)
 
 
 
 
 
151
  return out
152
  elif typ not in ("select", "chips"):
153
  typ = "select"
@@ -181,6 +188,10 @@ def _validate_and_normalize(raw: dict[str, Any]) -> dict[str, Any]:
181
  entry: dict[str, Any] = {"id": fid, "label": label, "type": typ}
182
  if typ in ("select", "chips") and isinstance(opts, list) and opts:
183
  entry["options"] = [str(o) for o in opts if str(o).strip()]
 
 
 
 
184
  fields_out.append(_coerce_field_selectable(entry))
185
  if len(fields_out) < 1:
186
  return _fallback_schema()
@@ -205,6 +216,10 @@ def _coerce_cached_schema(cached: dict[str, Any]) -> dict[str, Any]:
205
  opts = f.get("options")
206
  if typ in ("select", "chips") and isinstance(opts, list) and opts:
207
  entry["options"] = [str(o) for o in opts if str(o).strip()]
 
 
 
 
208
  fields_out.append(_coerce_field_selectable(entry))
209
  if len(fields_out) < 1:
210
  return _fallback_schema()
 
31
  "id": "stable_snake_case_id",
32
  "label": "Full question text shown to the user (no Q1/Q2 prefixes)",
33
  "type": "select" | "chips" | "number",
34
+ "options": ["required for select and chips: 3–12 distinct, short option strings"],
35
+ "unit": "ONLY for type number: short suffix shown next to the input (e.g. kg, lb, mm, in, %)"
36
  }
37
  ]
38
  }
 
43
  - **No open-ended typing:** NEVER use type "text" or "textarea". Users must tap choices only.
44
  - Prefer **select** with 5–12 concise options for objectives, scope, methodology, audience, timing, risk, quality level, etc.
45
  - Use **chips** for 3–8 mutually exclusive options when labels are short (single choice — same as select, shown as buttons).
46
+ - Use **number** only for true numeric values (counts, currency amounts, percentages, sizes, weights, dimensions).
47
+ - For **every number field**, set **"unit"** to the metric users should enter (e.g. `"kg"` for weight capacity, `"mm"` for seat depth, `"lb"` only if Imperial is explicit). Never leave unit ambiguous when the question is a measurement.
48
  - Every option string must be self-contained (no reliance on free-form explanations). If a case might need nuance, add an option such as "Other — see specification notes below".
49
  - Use stable `id` values (snake_case) — they are keys in saved data.
50
  - Same commodity must always get the same structure when regenerated; the app caches by commodity code, but ids and intent must stay consistent if you see similar commodities.
 
150
  elif typ == "number":
151
  out = {**entry, "type": "number"}
152
  out.pop("options", None)
153
+ unit = str(out.get("unit") or "").strip()
154
+ if unit:
155
+ out["unit"] = unit[:24]
156
+ else:
157
+ out.pop("unit", None)
158
  return out
159
  elif typ not in ("select", "chips"):
160
  typ = "select"
 
188
  entry: dict[str, Any] = {"id": fid, "label": label, "type": typ}
189
  if typ in ("select", "chips") and isinstance(opts, list) and opts:
190
  entry["options"] = [str(o) for o in opts if str(o).strip()]
191
+ if typ == "number":
192
+ u = str(f.get("unit") or "").strip()
193
+ if u:
194
+ entry["unit"] = u[:24]
195
  fields_out.append(_coerce_field_selectable(entry))
196
  if len(fields_out) < 1:
197
  return _fallback_schema()
 
216
  opts = f.get("options")
217
  if typ in ("select", "chips") and isinstance(opts, list) and opts:
218
  entry["options"] = [str(o) for o in opts if str(o).strip()]
219
+ if typ == "number":
220
+ u = str(f.get("unit") or "").strip()
221
+ if u:
222
+ entry["unit"] = u[:24]
223
  fields_out.append(_coerce_field_selectable(entry))
224
  if len(fields_out) < 1:
225
  return _fallback_schema()
server/pr_lines.py CHANGED
@@ -42,13 +42,18 @@ def delivery_dates(count: int, interval: str, year: int) -> list[str]:
42
  dates: list[date] = []
43
 
44
  if interval == "Quarterly":
45
- q_ends = [(3, 31), (6, 30), (9, 30), (12, 31)]
46
- i = 0
 
47
  yy = y
 
 
48
  while len(dates) < count:
49
- m, d = q_ends[i % 4]
50
- yy = y + i // 4
51
- dates.append(date(yy, m, d))
 
 
52
  i += 1
53
  elif interval == "Monthly":
54
  if count == 1:
 
42
  dates: list[date] = []
43
 
44
  if interval == "Quarterly":
45
+ # Anchor at March of `year`, then Mar → Jun → Sep → Mar (next year), …
46
+ # Skips December quarter-ends so multi-year schedules reach Q1 of the next year
47
+ # (e.g. 4 deliveries in 2026: Mar, Jun, Sep 2026, then Mar 2027).
48
  yy = y
49
+ trip = (3, 6, 9)
50
+ i = 0
51
  while len(dates) < count:
52
+ if i > 0 and i % 3 == 0:
53
+ yy += 1
54
+ m = trip[i % 3]
55
+ last = calendar.monthrange(yy, m)[1]
56
+ dates.append(date(yy, m, last))
57
  i += 1
58
  elif interval == "Monthly":
59
  if count == 1:
src/App.tsx CHANGED
@@ -133,15 +133,17 @@ export default function App() {
133
  }, [showFollowUpComposer]);
134
 
135
  const mergePrRows = useCallback((batch: PrRow[]) => {
 
136
  setAccumulatedPrRows((prev) => {
137
- const base = prev.length
138
- ? Math.max(...prev.map((r) => r.pr_line_item))
 
139
  : 0;
140
  const next = batch.map((r, i) => ({
141
  ...r,
142
  pr_line_item: base + 10 * (i + 1),
143
  }));
144
- return [...prev, ...next];
145
  });
146
  }, []);
147
 
 
133
  }, [showFollowUpComposer]);
134
 
135
  const mergePrRows = useCallback((batch: PrRow[]) => {
136
+ if (!batch.length) return;
137
  setAccumulatedPrRows((prev) => {
138
+ const prior = [...prev];
139
+ const base = prior.length
140
+ ? Math.max(...prior.map((r) => Number(r.pr_line_item) || 0))
141
  : 0;
142
  const next = batch.map((r, i) => ({
143
  ...r,
144
  pr_line_item: base + 10 * (i + 1),
145
  }));
146
+ return [...prior, ...next];
147
  });
148
  }, []);
149
 
src/api/form.ts CHANGED
@@ -3,6 +3,8 @@ export type DynamicField = {
3
  label: string;
4
  type: "select" | "number" | "text" | "chips" | "textarea";
5
  options?: string[];
 
 
6
  };
7
 
8
  export type FormSchemaResponse = {
 
3
  label: string;
4
  type: "select" | "number" | "text" | "chips" | "textarea";
5
  options?: string[];
6
+ /** Shown beside numeric inputs (from schema or inferred from label). */
7
+ unit?: string;
8
  };
9
 
10
  export type FormSchemaResponse = {
src/components/PrLineItemsTable.tsx CHANGED
@@ -16,7 +16,7 @@ export function PrLineItemsTable({ rows, className = "" }: Props) {
16
  <div className="border-b border-outline-variant bg-surface-container-high px-4 py-2.5 font-label-caps text-[10px] font-bold uppercase tracking-wide text-secondary">
17
  CLEAN PR — line items
18
  </div>
19
- <div className="overflow-x-auto">
20
  <table className="w-full min-w-[960px] table-fixed border-collapse text-left text-body-sm font-body-sm">
21
  <colgroup>
22
  <col className="w-[8%]" />
 
16
  <div className="border-b border-outline-variant bg-surface-container-high px-4 py-2.5 font-label-caps text-[10px] font-bold uppercase tracking-wide text-secondary">
17
  CLEAN PR — line items
18
  </div>
19
+ <div className="max-h-[min(70vh,560px)] overflow-auto">
20
  <table className="w-full min-w-[960px] table-fixed border-collapse text-left text-body-sm font-body-sm">
21
  <colgroup>
22
  <col className="w-[8%]" />
src/components/ProcurementFormBlock.tsx CHANGED
@@ -19,6 +19,31 @@ const CTRL =
19
  const LABEL_CLASS =
20
  "min-h-[3rem] flex items-end text-[11px] font-bold text-secondary leading-snug";
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  type Props = {
23
  commodityCode: number;
24
  intro: string;
@@ -69,6 +94,8 @@ export function ProcurementFormBlock({
69
  const [confirmBusy, setConfirmBusy] = useState(false);
70
  const [buildError, setBuildError] = useState<string | null>(null);
71
  const [justAdded, setJustAdded] = useState(false);
 
 
72
 
73
  useEffect(() => {
74
  if (!intervalChoices.length) return;
@@ -152,14 +179,31 @@ export function ProcurementFormBlock({
152
  }
153
 
154
  if (f.type === "number") {
 
155
  return (
156
  <FieldCell key={f.id} label={f.label}>
157
- <input
158
- type="number"
159
- className={`${CTRL} font-bold`}
160
- value={v}
161
- onChange={(e) => setDyn(f.id, e.target.value)}
162
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  </FieldCell>
164
  );
165
  }
@@ -212,7 +256,12 @@ export function ProcurementFormBlock({
212
  other_spec: otherSpec,
213
  year,
214
  });
 
 
 
 
215
  onCommitPrLines(rows);
 
216
  setJustAdded(true);
217
  window.setTimeout(() => setJustAdded(false), 5000);
218
  } catch (e) {
@@ -351,8 +400,14 @@ export function ProcurementFormBlock({
351
  </button>
352
  <button
353
  type="button"
 
 
 
 
 
 
354
  onClick={onAddNew}
355
- className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-outline-variant bg-white px-6 py-3 text-body-sm font-semibold text-secondary transition-colors hover:bg-surface-container-high"
356
  >
357
  Add new
358
  <Icon name="add" className="text-sm" />
 
19
  const LABEL_CLASS =
20
  "min-h-[3rem] flex items-end text-[11px] font-bold text-secondary leading-snug";
21
 
22
+ /** Unit suffix for number fields when schema omits `unit` (cached / legacy schemas). */
23
+ function inferredNumberUnit(label: string): string | undefined {
24
+ const L = label.toLowerCase();
25
+ if (/\b(kilograms?|kg)\b/i.test(label)) return "kg";
26
+ if (/\b(weight|mass|load capacity|weight capacity)\b/.test(L)) return "kg";
27
+ if (/\b(pounds?|lbs?)\b/i.test(L)) return "lb";
28
+ if (
29
+ /\b(millimeters?|millimetres?|mm)\b/i.test(L) ||
30
+ /\b(seat depth|seat width|seat height)\b/.test(L)
31
+ )
32
+ return "mm";
33
+ if (/\b(centimeters?|centimetres?|cm)\b/i.test(L)) return "cm";
34
+ if (/\b(inches|inch)\b|\bin\.\b/i.test(L)) return "in";
35
+ if (/\b(meters?|metres?)\b/i.test(L)) return "m";
36
+ if (/\bpercent\b|%/i.test(L)) return "%";
37
+ return undefined;
38
+ }
39
+
40
+ function unitBadgeText(unit: string): string {
41
+ const u = unit.trim();
42
+ if (!u) return u;
43
+ // Short measurement tokens: show uppercase (kg → KG); leave longer symbols alone.
44
+ return u.length <= 6 ? u.toUpperCase() : u;
45
+ }
46
+
47
  type Props = {
48
  commodityCode: number;
49
  intro: string;
 
94
  const [confirmBusy, setConfirmBusy] = useState(false);
95
  const [buildError, setBuildError] = useState<string | null>(null);
96
  const [justAdded, setJustAdded] = useState(false);
97
+ /** Enables "Add new" only after this block has successfully committed lines to the PR table. */
98
+ const [hasCommittedLines, setHasCommittedLines] = useState(false);
99
 
100
  useEffect(() => {
101
  if (!intervalChoices.length) return;
 
179
  }
180
 
181
  if (f.type === "number") {
182
+ const unit = (f.unit?.trim() || inferredNumberUnit(f.label)) ?? "";
183
  return (
184
  <FieldCell key={f.id} label={f.label}>
185
+ <div className="flex min-h-11 items-stretch gap-2">
186
+ <input
187
+ type="number"
188
+ className={`${CTRL} min-w-0 flex-1 font-bold`}
189
+ value={v}
190
+ onChange={(e) => setDyn(f.id, e.target.value)}
191
+ aria-describedby={unit ? `${f.id}-unit-hint` : undefined}
192
+ />
193
+ {unit ? (
194
+ <>
195
+ <span id={`${f.id}-unit-hint`} className="sr-only">
196
+ Enter value in {unitBadgeText(unit)}
197
+ </span>
198
+ <span
199
+ className="flex shrink-0 items-center rounded border border-outline-variant bg-surface-container-high px-3 text-xs font-bold uppercase tracking-wide text-secondary"
200
+ aria-hidden
201
+ >
202
+ {unitBadgeText(unit)}
203
+ </span>
204
+ </>
205
+ ) : null}
206
+ </div>
207
  </FieldCell>
208
  );
209
  }
 
256
  other_spec: otherSpec,
257
  year,
258
  });
259
+ if (!rows?.length) {
260
+ setBuildError("No line items were generated. Check deliveries and try again.");
261
+ return;
262
+ }
263
  onCommitPrLines(rows);
264
+ setHasCommittedLines(true);
265
  setJustAdded(true);
266
  window.setTimeout(() => setJustAdded(false), 5000);
267
  } catch (e) {
 
400
  </button>
401
  <button
402
  type="button"
403
+ disabled={!hasCommittedLines || confirmBusy}
404
+ title={
405
+ hasCommittedLines
406
+ ? "Add another catalogue line to this request"
407
+ : "Confirm selection first to add another line"
408
+ }
409
  onClick={onAddNew}
410
+ className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-outline-variant bg-white px-6 py-3 text-body-sm font-semibold text-secondary transition-colors hover:bg-surface-container-high disabled:cursor-not-allowed disabled:opacity-45"
411
  >
412
  Add new
413
  <Icon name="add" className="text-sm" />