Seth commited on
Commit
dd7dcdf
·
1 Parent(s): 865237e
src/App.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import type { ChatResponse } from "./api/chat";
3
  import { chatApi } from "./api/chat";
 
4
  import type { ThreadItem } from "./types";
5
  import { AnalysisCard } from "./components/AnalysisCard";
6
  import { DisambiguationCard } from "./components/DisambiguationCard";
@@ -8,6 +9,7 @@ import { Header } from "./components/Header";
8
  import { LandingHero } from "./components/LandingHero";
9
  import { LoadingCard } from "./components/LoadingCard";
10
  import { MainChatComposer } from "./components/MainChatComposer";
 
11
  import { ProcurementFormBlock } from "./components/ProcurementFormBlock";
12
  import { SuggestionCards } from "./components/SuggestionCards";
13
  import { UserBubble } from "./components/UserBubble";
@@ -83,7 +85,11 @@ export default function App() {
83
  const [editingUserId, setEditingUserId] = useState<string | null>(null);
84
  const [editDraft, setEditDraft] = useState("");
85
  const [busy, setBusy] = useState(false);
 
 
 
86
  const scrollRef = useRef<HTMLDivElement>(null);
 
87
 
88
  const scrollToBottom = useCallback(() => {
89
  requestAnimationFrame(() => {
@@ -95,16 +101,34 @@ export default function App() {
95
 
96
  useEffect(() => {
97
  scrollToBottom();
98
- }, [thread.length, busy, scrollToBottom]);
99
 
100
- const beginEdit = useCallback((id: string, currentText: string) => {
101
- setEditingUserId(id);
102
- setEditDraft(currentText);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }, []);
104
 
105
- const cancelEdit = useCallback(() => {
106
- setEditingUserId(null);
107
- setEditDraft("");
108
  }, []);
109
 
110
  const appendAssistant = useCallback(async (messageText: string, pick?: number | null) => {
@@ -138,6 +162,26 @@ export default function App() {
138
  }
139
  }, []);
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  const send = useCallback(async () => {
142
  const text = draft.trim();
143
  if (!text || busy) return;
@@ -182,7 +226,7 @@ export default function App() {
182
  >
183
  <div
184
  ref={scrollRef}
185
- className={`flex-1 overflow-y-auto min-h-0 w-full max-w-4xl mx-auto px-gutter ${
186
  showLanding ? "flex flex-col" : "pb-8 pt-2"
187
  }`}
188
  >
@@ -237,10 +281,31 @@ export default function App() {
237
  intro={item.intro}
238
  catalogPath={item.catalogPath}
239
  codeSummary={item.codeSummary}
 
 
240
  />
241
  );
242
  })}
243
  {busy ? <LoadingCard /> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  <p className="pb-4 text-center font-body-sm text-xs text-secondary/60">
245
  ProcureMind AI can make mistakes. Check important procurement
246
  details before approval.
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import type { ChatResponse } from "./api/chat";
3
  import { chatApi } from "./api/chat";
4
+ import type { PrRow } from "./api/form";
5
  import type { ThreadItem } from "./types";
6
  import { AnalysisCard } from "./components/AnalysisCard";
7
  import { DisambiguationCard } from "./components/DisambiguationCard";
 
9
  import { LandingHero } from "./components/LandingHero";
10
  import { LoadingCard } from "./components/LoadingCard";
11
  import { MainChatComposer } from "./components/MainChatComposer";
12
+ import { PrLineItemsTable } from "./components/PrLineItemsTable";
13
  import { ProcurementFormBlock } from "./components/ProcurementFormBlock";
14
  import { SuggestionCards } from "./components/SuggestionCards";
15
  import { UserBubble } from "./components/UserBubble";
 
85
  const [editingUserId, setEditingUserId] = useState<string | null>(null);
86
  const [editDraft, setEditDraft] = useState("");
87
  const [busy, setBusy] = useState(false);
88
+ const [accumulatedPrRows, setAccumulatedPrRows] = useState<PrRow[]>([]);
89
+ const [showFollowUpComposer, setShowFollowUpComposer] = useState(false);
90
+ const [followUpDraft, setFollowUpDraft] = useState("");
91
  const scrollRef = useRef<HTMLDivElement>(null);
92
+ const followUpRef = useRef<HTMLDivElement>(null);
93
 
94
  const scrollToBottom = useCallback(() => {
95
  requestAnimationFrame(() => {
 
101
 
102
  useEffect(() => {
103
  scrollToBottom();
104
+ }, [thread.length, busy, accumulatedPrRows.length, scrollToBottom]);
105
 
106
+ useEffect(() => {
107
+ if (!showFollowUpComposer) return;
108
+ const id = window.setTimeout(() => {
109
+ followUpRef.current?.scrollIntoView({
110
+ behavior: "smooth",
111
+ block: "nearest",
112
+ });
113
+ }, 120);
114
+ return () => window.clearTimeout(id);
115
+ }, [showFollowUpComposer]);
116
+
117
+ const mergePrRows = useCallback((batch: PrRow[]) => {
118
+ setAccumulatedPrRows((prev) => {
119
+ const base = prev.length
120
+ ? Math.max(...prev.map((r) => r.pr_line_item))
121
+ : 0;
122
+ const next = batch.map((r, i) => ({
123
+ ...r,
124
+ pr_line_item: base + 10 * (i + 1),
125
+ }));
126
+ return [...prev, ...next];
127
+ });
128
  }, []);
129
 
130
+ const openAddLineComposer = useCallback(() => {
131
+ setShowFollowUpComposer(true);
 
132
  }, []);
133
 
134
  const appendAssistant = useCallback(async (messageText: string, pick?: number | null) => {
 
162
  }
163
  }, []);
164
 
165
+ const sendFollowUp = useCallback(async () => {
166
+ const text = followUpDraft.trim();
167
+ if (!text || busy) return;
168
+ setFollowUpDraft("");
169
+ setShowFollowUpComposer(false);
170
+ const user: ThreadItem = { id: uid(), type: "user", text };
171
+ setThread((prev) => [...prev, user]);
172
+ await appendAssistant(text, null);
173
+ }, [followUpDraft, busy, appendAssistant]);
174
+
175
+ const beginEdit = useCallback((id: string, currentText: string) => {
176
+ setEditingUserId(id);
177
+ setEditDraft(currentText);
178
+ }, []);
179
+
180
+ const cancelEdit = useCallback(() => {
181
+ setEditingUserId(null);
182
+ setEditDraft("");
183
+ }, []);
184
+
185
  const send = useCallback(async () => {
186
  const text = draft.trim();
187
  if (!text || busy) return;
 
226
  >
227
  <div
228
  ref={scrollRef}
229
+ className={`flex-1 overflow-y-auto min-h-0 w-full max-w-6xl mx-auto px-gutter ${
230
  showLanding ? "flex flex-col" : "pb-8 pt-2"
231
  }`}
232
  >
 
281
  intro={item.intro}
282
  catalogPath={item.catalogPath}
283
  codeSummary={item.codeSummary}
284
+ onCommitPrLines={mergePrRows}
285
+ onAddNew={openAddLineComposer}
286
  />
287
  );
288
  })}
289
  {busy ? <LoadingCard /> : null}
290
+ {accumulatedPrRows.length > 0 ? (
291
+ <PrLineItemsTable rows={accumulatedPrRows} />
292
+ ) : null}
293
+ {showFollowUpComposer ? (
294
+ <div
295
+ ref={followUpRef}
296
+ className="mt-2 rounded-2xl border border-outline-variant bg-surface-container-low p-4 shadow-sm"
297
+ >
298
+ <p className="mb-3 font-body-sm text-secondary">
299
+ Add another catalogue line to this same purchase request.
300
+ Your chat continues below—this is not a new request.
301
+ </p>
302
+ <MainChatComposer
303
+ value={followUpDraft}
304
+ onChange={setFollowUpDraft}
305
+ onSend={sendFollowUp}
306
+ />
307
+ </div>
308
+ ) : null}
309
  <p className="pb-4 text-center font-body-sm text-xs text-secondary/60">
310
  ProcureMind AI can make mistakes. Check important procurement
311
  details before approval.
src/components/Header.tsx CHANGED
@@ -2,7 +2,8 @@ import { Icon } from "./Icon";
2
 
3
  export function Header() {
4
  return (
5
- <header className="h-16 px-gutter border-b border-outline-variant flex items-center justify-between bg-surface z-50 shrink-0">
 
6
  <div className="flex items-center gap-8">
7
  <div className="flex flex-col">
8
  <h1 className="font-headline-md text-xl font-bold text-primary leading-tight">
@@ -81,6 +82,7 @@ export function Header() {
81
  </div>
82
  </div>
83
  </div>
 
84
  </header>
85
  );
86
  }
 
2
 
3
  export function Header() {
4
  return (
5
+ <header className="h-16 border-b border-outline-variant bg-surface z-50 shrink-0">
6
+ <div className="mx-auto flex h-full w-full max-w-6xl items-center justify-between px-gutter">
7
  <div className="flex items-center gap-8">
8
  <div className="flex flex-col">
9
  <h1 className="font-headline-md text-xl font-bold text-primary leading-tight">
 
82
  </div>
83
  </div>
84
  </div>
85
+ </div>
86
  </header>
87
  );
88
  }
src/components/MainChatComposer.tsx CHANGED
@@ -24,7 +24,7 @@ export function MainChatComposer({
24
 
25
  return (
26
  <div
27
- className={`w-full max-w-3xl mx-auto ${compact ? "" : "px-1"}`}
28
  >
29
  <div className="relative rounded-2xl border border-outline-variant bg-white p-2 shadow-md transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/10">
30
  <textarea
 
24
 
25
  return (
26
  <div
27
+ className={`w-full max-w-5xl mx-auto ${compact ? "" : "px-1"}`}
28
  >
29
  <div className="relative rounded-2xl border border-outline-variant bg-white p-2 shadow-md transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/10">
30
  <textarea
src/components/PrLineItemsTable.tsx CHANGED
@@ -8,47 +8,71 @@ export function PrLineItemsTable({ rows }: Props) {
8
  if (!rows.length) return null;
9
 
10
  return (
11
- <div className="mt-6 overflow-x-auto rounded-xl border border-outline-variant bg-white shadow-sm">
12
- <div className="border-b border-outline-variant bg-[#fff9c4]/90 px-4 py-2 font-label-caps text-[10px] font-bold uppercase tracking-wide text-primary">
13
  CLEAN PR — line items
14
  </div>
15
- <table className="w-full min-w-[720px] text-left text-body-sm font-body-sm">
16
- <thead>
17
- <tr className="border-b border-outline-variant bg-[#fff9c4]/70">
18
- <th className="px-3 py-2 font-semibold text-primary">PR Line Item</th>
19
- <th className="px-3 py-2 font-semibold text-primary">MAT GRP</th>
20
- <th className="px-3 py-2 font-semibold text-primary">PR SHORT TEXT</th>
21
- <th className="px-3 py-2 font-semibold text-primary">PR LONG TEXT</th>
22
- <th className="px-3 py-2 font-semibold text-primary">PR Quantity</th>
23
- <th className="px-3 py-2 font-semibold text-primary">Delivery Date</th>
24
- </tr>
25
- </thead>
26
- <tbody>
27
- {rows.map((r) => (
28
- <tr
29
- key={r.pr_line_item}
30
- className="border-b border-outline-variant/80 align-top last:border-0"
31
- >
32
- <td className="px-3 py-2 font-data-mono text-data-mono whitespace-nowrap">
33
- {r.pr_line_item}
34
- </td>
35
- <td className="px-3 py-2 font-data-mono text-data-mono whitespace-nowrap">
36
- {r.mat_grp}
37
- </td>
38
- <td className="px-3 py-2 text-on-background break-words max-w-[220px]">
39
- {r.pr_short_text}
40
- </td>
41
- <td className="px-3 py-2 text-on-background break-words max-w-[280px]">
42
- {r.pr_long_text}
43
- </td>
44
- <td className="px-3 py-2 whitespace-nowrap">{r.pr_quantity}</td>
45
- <td className="px-3 py-2 whitespace-nowrap text-secondary">
46
- {r.delivery_date}
47
- </td>
48
  </tr>
49
- ))}
50
- </tbody>
51
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </div>
53
  );
54
  }
 
8
  if (!rows.length) return null;
9
 
10
  return (
11
+ <div className="mt-6 w-full overflow-hidden rounded-xl border border-outline-variant bg-white shadow-sm">
12
+ <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">
13
  CLEAN PR — line items
14
  </div>
15
+ <div className="overflow-x-auto">
16
+ <table className="w-full min-w-[960px] table-fixed border-collapse text-left text-body-sm font-body-sm">
17
+ <colgroup>
18
+ <col className="w-[8%]" />
19
+ <col className="w-[10%]" />
20
+ <col className="w-[22%]" />
21
+ <col className="w-[30%]" />
22
+ <col className="w-[8%]" />
23
+ <col className="w-[12%]" />
24
+ </colgroup>
25
+ <thead>
26
+ <tr className="border-b border-outline-variant bg-surface-container-low">
27
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary">
28
+ PR Line Item
29
+ </th>
30
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary">
31
+ MAT GRP
32
+ </th>
33
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary">
34
+ PR SHORT TEXT
35
+ </th>
36
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary">
37
+ PR LONG TEXT
38
+ </th>
39
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary text-center">
40
+ PR Quantity
41
+ </th>
42
+ <th className="px-3 py-2.5 align-bottom font-semibold text-primary">
43
+ Delivery Date
44
+ </th>
 
 
 
45
  </tr>
46
+ </thead>
47
+ <tbody>
48
+ {rows.map((r, idx) => (
49
+ <tr
50
+ key={`${r.pr_line_item}-${idx}`}
51
+ className="border-b border-outline-variant/80 align-top last:border-0"
52
+ >
53
+ <td className="px-3 py-2.5 font-data-mono text-data-mono whitespace-nowrap">
54
+ {r.pr_line_item}
55
+ </td>
56
+ <td className="px-3 py-2.5 font-data-mono text-data-mono whitespace-nowrap">
57
+ {r.mat_grp}
58
+ </td>
59
+ <td className="min-w-0 px-3 py-2.5 break-words text-on-background">
60
+ {r.pr_short_text}
61
+ </td>
62
+ <td className="min-w-0 px-3 py-2.5 break-words text-on-background">
63
+ {r.pr_long_text}
64
+ </td>
65
+ <td className="px-3 py-2.5 text-center whitespace-nowrap tabular-nums">
66
+ {r.pr_quantity}
67
+ </td>
68
+ <td className="min-w-0 px-3 py-2.5 whitespace-nowrap text-secondary">
69
+ {r.delivery_date}
70
+ </td>
71
+ </tr>
72
+ ))}
73
+ </tbody>
74
+ </table>
75
+ </div>
76
  </div>
77
  );
78
  }
src/components/ProcurementFormBlock.tsx CHANGED
@@ -1,8 +1,7 @@
1
- import { useEffect, useState } from "react";
2
  import type { DynamicField, PrRow } from "../api/form";
3
  import { buildPrLines, fetchFormSchema } from "../api/form";
4
  import { Icon } from "./Icon";
5
- import { PrLineItemsTable } from "./PrLineItemsTable";
6
 
7
  const FALLBACK_INTERVALS = [
8
  "Daily",
@@ -14,18 +13,47 @@ const FALLBACK_INTERVALS = [
14
  "Annual",
15
  ];
16
 
 
 
 
 
 
 
17
  type Props = {
18
  commodityCode: number;
19
  intro: string;
20
  catalogPath?: string;
21
  codeSummary?: string;
 
 
22
  };
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export function ProcurementFormBlock({
25
  commodityCode,
26
  intro,
27
  catalogPath,
28
  codeSummary,
 
 
29
  }: Props) {
30
  const [schemaLoading, setSchemaLoading] = useState(true);
31
  const [schemaError, setSchemaError] = useState<string | null>(null);
@@ -38,9 +66,9 @@ export function ProcurementFormBlock({
38
  const [otherSpec, setOtherSpec] = useState("");
39
  const [dynamicValues, setDynamicValues] = useState<Record<string, string>>({});
40
 
41
- const [prRows, setPrRows] = useState<PrRow[] | null>(null);
42
  const [confirmBusy, setConfirmBusy] = useState(false);
43
  const [buildError, setBuildError] = useState<string | null>(null);
 
44
 
45
  useEffect(() => {
46
  if (!intervalChoices.length) return;
@@ -80,15 +108,12 @@ export function ProcurementFormBlock({
80
 
81
  const renderDynamicField = (f: DynamicField) => {
82
  const v = dynamicValues[f.id] ?? "";
83
- const labelCls =
84
- "block text-[11px] font-bold text-secondary mb-1.5 leading-snug";
85
 
86
  if (f.type === "select" && f.options?.length) {
87
  return (
88
- <div key={f.id} className="col-span-12 md:col-span-6">
89
- <label className={labelCls}>{f.label}</label>
90
  <select
91
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-semibold focus:ring-primary focus:border-primary"
92
  value={v}
93
  onChange={(e) => setDyn(f.id, e.target.value)}
94
  >
@@ -99,76 +124,77 @@ export function ProcurementFormBlock({
99
  </option>
100
  ))}
101
  </select>
102
- </div>
103
  );
104
  }
105
 
106
  if (f.type === "chips" && f.options?.length) {
107
  return (
108
- <div key={f.id} className="col-span-12">
109
- <label className={labelCls}>{f.label}</label>
110
- <div className="flex flex-wrap gap-2">
111
  {f.options.map((o) => (
112
  <button
113
  key={o}
114
  type="button"
115
  onClick={() => setDyn(f.id, o)}
116
- className={`px-3 py-1.5 rounded text-xs font-semibold transition-colors ${
117
  v === o
118
  ? "bg-primary text-on-primary"
119
- : "bg-surface-container-low border border-outline-variant text-secondary hover:bg-surface-container-high"
120
  }`}
121
  >
122
  {o}
123
  </button>
124
  ))}
125
  </div>
126
- </div>
127
  );
128
  }
129
 
130
  if (f.type === "number") {
131
  return (
132
- <div key={f.id} className="col-span-12 md:col-span-6">
133
- <label className={labelCls}>{f.label}</label>
134
  <input
135
  type="number"
136
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-bold"
137
  value={v}
138
  onChange={(e) => setDyn(f.id, e.target.value)}
139
  />
140
- </div>
141
  );
142
  }
143
 
144
  if (f.type === "textarea") {
145
  return (
146
- <div key={f.id} className="col-span-12">
147
- <label className={labelCls}>{f.label}</label>
 
 
 
148
  <textarea
149
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-semibold min-h-[72px]"
150
  value={v}
151
  onChange={(e) => setDyn(f.id, e.target.value)}
152
  />
153
- </div>
154
  );
155
  }
156
 
157
  return (
158
- <div key={f.id} className="col-span-12 md:col-span-6">
159
- <label className={labelCls}>{f.label}</label>
160
  <input
161
  type="text"
162
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-semibold"
163
  value={v}
164
  onChange={(e) => setDyn(f.id, e.target.value)}
165
  />
166
- </div>
167
  );
168
  };
169
 
170
  const onConfirm = async () => {
171
  setBuildError(null);
 
172
  setConfirmBusy(true);
173
  try {
174
  const nums: Record<string, string | number> = { ...dynamicValues };
@@ -186,9 +212,10 @@ export function ProcurementFormBlock({
186
  other_spec: otherSpec,
187
  year,
188
  });
189
- setPrRows(rows);
 
 
190
  } catch (e) {
191
- setPrRows(null);
192
  setBuildError(e instanceof Error ? e.message : String(e));
193
  } finally {
194
  setConfirmBusy(false);
@@ -204,7 +231,7 @@ export function ProcurementFormBlock({
204
  className="text-on-secondary-container text-sm"
205
  />
206
  </div>
207
- <div className="flex-1 max-w-[85%]">
208
  <div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant">
209
  <p className="font-body-md text-body-md text-primary mb-4">{intro}</p>
210
  {(catalogPath || codeSummary) && (
@@ -237,28 +264,22 @@ export function ProcurementFormBlock({
237
  Line Item Details
238
  </span>
239
  </div>
240
- <div className="p-4 grid grid-cols-12 gap-y-4 gap-x-6">
241
- <div className="col-span-12 md:col-span-4">
242
- <label className="block text-[11px] font-bold text-secondary mb-1.5">
243
- Number of deliveries
244
- </label>
245
  <input
246
  type="number"
247
  min={1}
248
  max={52}
249
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-bold"
250
  value={deliveries}
251
  onChange={(e) =>
252
  setDeliveries(Math.max(1, Number(e.target.value) || 1))
253
  }
254
  />
255
- </div>
256
- <div className="col-span-12 md:col-span-4">
257
- <label className="block text-[11px] font-bold text-secondary mb-1.5">
258
- Interval of deliveries
259
- </label>
260
  <select
261
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-semibold focus:ring-primary focus:border-primary"
262
  value={interval}
263
  onChange={(e) => setInterval(e.target.value)}
264
  >
@@ -268,43 +289,40 @@ export function ProcurementFormBlock({
268
  </option>
269
  ))}
270
  </select>
271
- </div>
272
- <div className="col-span-12 md:col-span-4">
273
- <label className="block text-[11px] font-bold text-secondary mb-1.5">
274
- Year (for delivery schedule)
275
- </label>
276
  <input
277
  type="number"
278
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-bold"
279
  value={year}
280
  onChange={(e) =>
281
  setYear(Number(e.target.value) || new Date().getFullYear())
282
  }
283
  />
284
- </div>
285
 
286
  {fields.map((f) => renderDynamicField(f))}
287
 
288
- <div className="col-span-12">
289
- <label className="block text-[11px] font-bold text-secondary mb-1.5">
290
- Other specification (free text)
291
- </label>
292
  <textarea
293
- className="w-full p-2.5 bg-surface-container-low rounded border border-outline-variant text-body-sm font-semibold min-h-[72px]"
294
  placeholder="e.g. Age 18+"
295
  value={otherSpec}
296
  onChange={(e) => setOtherSpec(e.target.value)}
297
  />
298
- </div>
299
 
300
- <div className="col-span-12 bg-[#10B981]/10 p-3 rounded-lg flex items-center justify-between border border-[#10B981]/20 mt-2">
301
  <div className="flex items-center gap-2">
302
  <Icon name="check_circle" className="text-[#10B981]" />
303
  <span className="text-body-sm font-semibold text-[#10B981]">
304
  Budget Impact: Within Allocation
305
  </span>
306
  </div>
307
- <span className="text-xs text-[#10B981] font-bold">
308
  AVAILABLE
309
  </span>
310
  </div>
@@ -314,29 +332,33 @@ export function ProcurementFormBlock({
314
  {buildError ? (
315
  <p className="mb-3 text-body-sm text-error">{buildError}</p>
316
  ) : null}
 
 
 
 
 
317
 
318
- <div className="pt-4 border-t border-outline-variant">
319
- <div className="flex flex-col sm:flex-row gap-3">
320
  <button
321
  type="button"
322
  disabled={confirmBusy || schemaLoading}
323
  onClick={onConfirm}
324
- className="flex-1 bg-primary text-white px-6 py-3 rounded-xl text-body-sm font-bold flex items-center justify-center gap-2 hover:opacity-90 shadow-sm transition-all active:scale-95 disabled:opacity-50"
325
  >
326
  {confirmBusy ? "Building…" : "Confirm Selection"}
327
  <Icon name="task_alt" className="text-sm" />
328
  </button>
329
  <button
330
  type="button"
331
- className="flex-1 bg-white border border-outline-variant text-secondary px-6 py-3 rounded-xl text-body-sm font-semibold hover:bg-surface-container-high transition-colors flex items-center justify-center gap-2"
 
332
  >
333
- Search for Alternatives
334
- <Icon name="search" className="text-sm" />
335
  </button>
336
  </div>
337
  </div>
338
-
339
- {prRows ? <PrLineItemsTable rows={prRows} /> : null}
340
  </div>
341
  </div>
342
  </div>
 
1
+ import { useEffect, useState, type ReactNode } from "react";
2
  import type { DynamicField, PrRow } from "../api/form";
3
  import { buildPrLines, fetchFormSchema } from "../api/form";
4
  import { Icon } from "./Icon";
 
5
 
6
  const FALLBACK_INTERVALS = [
7
  "Daily",
 
13
  "Annual",
14
  ];
15
 
16
+ /** Aligns label baselines for side-by-side fields; controls share one height. */
17
+ const CTRL =
18
+ "h-11 w-full box-border rounded border border-outline-variant bg-surface-container-low px-2.5 text-body-sm";
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;
25
  catalogPath?: string;
26
  codeSummary?: string;
27
+ onCommitPrLines: (rows: PrRow[]) => void;
28
+ onAddNew: () => void;
29
  };
30
 
31
+ function FieldCell({
32
+ label,
33
+ children,
34
+ className = "col-span-12 md:col-span-6",
35
+ }: {
36
+ label: string;
37
+ children: ReactNode;
38
+ className?: string;
39
+ }) {
40
+ return (
41
+ <div
42
+ className={`flex flex-col gap-1.5 ${className}`}
43
+ >
44
+ <span className={LABEL_CLASS}>{label}</span>
45
+ {children}
46
+ </div>
47
+ );
48
+ }
49
+
50
  export function ProcurementFormBlock({
51
  commodityCode,
52
  intro,
53
  catalogPath,
54
  codeSummary,
55
+ onCommitPrLines,
56
+ onAddNew,
57
  }: Props) {
58
  const [schemaLoading, setSchemaLoading] = useState(true);
59
  const [schemaError, setSchemaError] = useState<string | null>(null);
 
66
  const [otherSpec, setOtherSpec] = useState("");
67
  const [dynamicValues, setDynamicValues] = useState<Record<string, string>>({});
68
 
 
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;
 
108
 
109
  const renderDynamicField = (f: DynamicField) => {
110
  const v = dynamicValues[f.id] ?? "";
 
 
111
 
112
  if (f.type === "select" && f.options?.length) {
113
  return (
114
+ <FieldCell key={f.id} label={f.label}>
 
115
  <select
116
+ className={`${CTRL} font-semibold focus:border-primary focus:ring-1 focus:ring-primary`}
117
  value={v}
118
  onChange={(e) => setDyn(f.id, e.target.value)}
119
  >
 
124
  </option>
125
  ))}
126
  </select>
127
+ </FieldCell>
128
  );
129
  }
130
 
131
  if (f.type === "chips" && f.options?.length) {
132
  return (
133
+ <FieldCell key={f.id} label={f.label} className="col-span-12">
134
+ <div className="flex min-h-11 flex-wrap items-center gap-2">
 
135
  {f.options.map((o) => (
136
  <button
137
  key={o}
138
  type="button"
139
  onClick={() => setDyn(f.id, o)}
140
+ className={`min-h-11 rounded px-3 py-2 text-xs font-semibold transition-colors ${
141
  v === o
142
  ? "bg-primary text-on-primary"
143
+ : "border border-outline-variant bg-surface-container-low text-secondary hover:bg-surface-container-high"
144
  }`}
145
  >
146
  {o}
147
  </button>
148
  ))}
149
  </div>
150
+ </FieldCell>
151
  );
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
  }
166
 
167
  if (f.type === "textarea") {
168
  return (
169
+ <FieldCell
170
+ key={f.id}
171
+ label={f.label}
172
+ className="col-span-12"
173
+ >
174
  <textarea
175
+ className="min-h-[5.5rem] w-full box-border rounded border border-outline-variant bg-surface-container-low p-2.5 text-body-sm font-semibold"
176
  value={v}
177
  onChange={(e) => setDyn(f.id, e.target.value)}
178
  />
179
+ </FieldCell>
180
  );
181
  }
182
 
183
  return (
184
+ <FieldCell key={f.id} label={f.label}>
 
185
  <input
186
  type="text"
187
+ className={`${CTRL} font-semibold`}
188
  value={v}
189
  onChange={(e) => setDyn(f.id, e.target.value)}
190
  />
191
+ </FieldCell>
192
  );
193
  };
194
 
195
  const onConfirm = async () => {
196
  setBuildError(null);
197
+ setJustAdded(false);
198
  setConfirmBusy(true);
199
  try {
200
  const nums: Record<string, string | number> = { ...dynamicValues };
 
212
  other_spec: otherSpec,
213
  year,
214
  });
215
+ onCommitPrLines(rows);
216
+ setJustAdded(true);
217
+ window.setTimeout(() => setJustAdded(false), 5000);
218
  } catch (e) {
 
219
  setBuildError(e instanceof Error ? e.message : String(e));
220
  } finally {
221
  setConfirmBusy(false);
 
231
  className="text-on-secondary-container text-sm"
232
  />
233
  </div>
234
+ <div className="min-w-0 flex-1">
235
  <div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant">
236
  <p className="font-body-md text-body-md text-primary mb-4">{intro}</p>
237
  {(catalogPath || codeSummary) && (
 
264
  Line Item Details
265
  </span>
266
  </div>
267
+ <div className="grid grid-cols-12 gap-x-6 gap-y-5 p-4">
268
+ <FieldCell label="Number of deliveries" className="col-span-12 md:col-span-4">
 
 
 
269
  <input
270
  type="number"
271
  min={1}
272
  max={52}
273
+ className={`${CTRL} font-bold`}
274
  value={deliveries}
275
  onChange={(e) =>
276
  setDeliveries(Math.max(1, Number(e.target.value) || 1))
277
  }
278
  />
279
+ </FieldCell>
280
+ <FieldCell label="Interval of deliveries" className="col-span-12 md:col-span-4">
 
 
 
281
  <select
282
+ className={`${CTRL} font-semibold focus:border-primary focus:ring-1 focus:ring-primary`}
283
  value={interval}
284
  onChange={(e) => setInterval(e.target.value)}
285
  >
 
289
  </option>
290
  ))}
291
  </select>
292
+ </FieldCell>
293
+ <FieldCell
294
+ label="Year (for delivery schedule)"
295
+ className="col-span-12 md:col-span-4"
296
+ >
297
  <input
298
  type="number"
299
+ className={`${CTRL} font-bold`}
300
  value={year}
301
  onChange={(e) =>
302
  setYear(Number(e.target.value) || new Date().getFullYear())
303
  }
304
  />
305
+ </FieldCell>
306
 
307
  {fields.map((f) => renderDynamicField(f))}
308
 
309
+ <FieldCell label="Other specification (free text)" className="col-span-12">
 
 
 
310
  <textarea
311
+ className="min-h-[5.5rem] w-full box-border rounded border border-outline-variant bg-surface-container-low p-2.5 text-body-sm font-semibold"
312
  placeholder="e.g. Age 18+"
313
  value={otherSpec}
314
  onChange={(e) => setOtherSpec(e.target.value)}
315
  />
316
+ </FieldCell>
317
 
318
+ <div className="col-span-12 flex flex-col gap-2 rounded-lg border border-[#10B981]/20 bg-[#10B981]/10 p-3 sm:flex-row sm:items-center sm:justify-between">
319
  <div className="flex items-center gap-2">
320
  <Icon name="check_circle" className="text-[#10B981]" />
321
  <span className="text-body-sm font-semibold text-[#10B981]">
322
  Budget Impact: Within Allocation
323
  </span>
324
  </div>
325
+ <span className="text-xs font-bold text-[#10B981]">
326
  AVAILABLE
327
  </span>
328
  </div>
 
332
  {buildError ? (
333
  <p className="mb-3 text-body-sm text-error">{buildError}</p>
334
  ) : null}
335
+ {justAdded ? (
336
+ <p className="mb-3 text-body-sm font-semibold text-[#10B981]">
337
+ Line items added to the CLEAN PR table below.
338
+ </p>
339
+ ) : null}
340
 
341
+ <div className="border-t border-outline-variant pt-4">
342
+ <div className="flex flex-col gap-3 sm:flex-row">
343
  <button
344
  type="button"
345
  disabled={confirmBusy || schemaLoading}
346
  onClick={onConfirm}
347
+ className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-6 py-3 text-body-sm font-bold text-white shadow-sm transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
348
  >
349
  {confirmBusy ? "Building…" : "Confirm Selection"}
350
  <Icon name="task_alt" className="text-sm" />
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" />
359
  </button>
360
  </div>
361
  </div>
 
 
362
  </div>
363
  </div>
364
  </div>
src/components/SuggestionCards.tsx CHANGED
@@ -23,7 +23,7 @@ const CARDS = [
23
 
24
  export function SuggestionCards({ onPick }: Props) {
25
  return (
26
- <div className="grid gap-4 sm:grid-cols-2 w-full max-w-3xl mx-auto px-1 pb-8">
27
  {CARDS.map((c) => (
28
  <button
29
  key={c.title}
 
23
 
24
  export function SuggestionCards({ onPick }: Props) {
25
  return (
26
+ <div className="grid gap-4 sm:grid-cols-2 w-full max-w-5xl mx-auto px-1 pb-8">
27
  {CARDS.map((c) => (
28
  <button
29
  key={c.title}