1"use client";
2
3
30import { useCallback, useEffect, useRef, useState } from "react";
31import { useQueryClient } from "@tanstack/react-query";
32import { CheckCheck, Loader2, Sigma } from "lucide-react";
33import { dbAiApi, displayStoredValue, type DbFormulaResult } from "@/lib/ai-database";
34import { ApiError } from "@/lib/api";
35import { recordTitle, type DatabaseProperty, type RecordDto } from "@/hooks/use-database";
36import { DbAiDialog, DbAiErrorBanner, DbAiLoading } from "@/components/ai/db-ai-dialog";
37
38export interface AiFormulaDialogProps {
39 open: boolean;
40 workspaceId: string;
41 databaseId: string;
42
43 properties: DatabaseProperty[];
44
45 records?: RecordDto[];
46 onClose: () => void;
47}
48
49const MAX_DESC_CHARS = 500;
50
51export function AiFormulaDialog({ open, workspaceId, databaseId, properties, records, onClose }: AiFormulaDialogProps) {
52 const qc = useQueryClient();
53 const [description, setDescription] = useState("");
54 const [loading, setLoading] = useState(false);
55 const [error, setError] = useState<{ code: string; message: string; parseError?: string } | null>(null);
56 const [result, setResult] = useState<DbFormulaResult | null>(null);
57 const lastDesc = useRef("");
58
59 useEffect(() => {
60 if (open) {
61 setDescription("");
62 setResult(null);
63 setError(null);
64 setLoading(false);
65 }
66 }, [open]);
67
68 const recordById = useRef(new Map<string, { title: string | null }>());
69 recordById.current = new Map((records ?? []).map((r) => [r.id, { title: recordTitle(r, properties) }]));
70 const propertyById = useRef(new Map<string, DatabaseProperty>());
71 propertyById.current = new Map(properties.map((p) => [p.id, p]));
72
73 const invalid = description.trim().length === 0;
74
75 const run = useCallback(
76 async (desc: string) => {
77 lastDesc.current = desc;
78 setLoading(true);
79 setError(null);
80 setResult(null);
81 try {
82 const res = await dbAiApi.generateFormula(workspaceId, { databaseId, description: desc });
83 setResult(res);
84 void qc.invalidateQueries({ queryKey: ["database", databaseId] });
85 } catch (err) {
86 if (err instanceof ApiError) {
87 const detail = (err.details as Array<Record<string, unknown>> | undefined)?.[0];
88 setError({
89 code: err.code,
90 message: err.message,
91 ...(typeof detail?.parseError === "string" ? { parseError: detail.parseError } : {})
92 });
93 } else {
94 setError({ code: "NETWORK_ERROR", message: "Could not reach the server." });
95 }
96 } finally {
97 setLoading(false);
98 }
99 },
100 [workspaceId, databaseId, qc]
101 );
102
103 const generate = useCallback(() => {
104 void run(description.trim());
105 }, [run, description]);
106
107 const retry = useCallback(() => {
108 void run(lastDesc.current);
109 }, [run]);
110
111 const close = useCallback(() => {
112 onClose();
113 }, [onClose]);
114
115 const formulaProp = result ? propertyById.current.get(result.propertyId) : undefined;
116 const budget = error?.code === "AI_BUDGET_EXCEEDED" || error?.code === "AI_BUDGET_MONTHLY_EXCEEDED";
117 const invalidStructure = error?.code === "UNPROCESSABLE_ENTITY";
118
119 const live = loading
120 ? "AI is generating a formula"
121 : result
122 ? `Formula generated: ${result.expression}`
123 : error
124 ? `Error: ${error.message}`
125 : "";
126
127 return (
128 <DbAiDialog
129 open={open}
130 title="Generate column with formula"
131 testId="db-formula"
132 onClose={close}
133 live={live}
134 footer={
135 <>
136 <span className="min-w-0 truncate text-[11px] text-muted-foreground">
137 {result ? "Formula validated and saved" : "Validated by the formula engine before it is saved"}
138 </span>
139 <button
140 type="button"
141 data-testid="db-formula-generate"
142 onClick={() => void generate()}
143 disabled={invalid || loading}
144 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
145 >
146 {loading ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : <Sigma className="size-3.5" aria-hidden />}
147 Generate
148 </button>
149 </>
150 }
151 >
152 <label className="block text-xs font-medium text-muted-foreground" htmlFor="db-formula-desc">
153 Describe the column to create
154 </label>
155 <textarea
156 id="db-formula-desc"
157 data-testid="db-formula-desc"
158 value={description}
159 onChange={(e) => setDescription(e.target.value)}
160 onKeyDown={(e) => {
161 if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !invalid && !loading) {
162 e.preventDefault();
163 void generate();
164 }
165 }}
166 rows={3}
167 placeholder={'e.g. "a 100-point score when the title says Done, otherwise 0"'}
168 maxLength={MAX_DESC_CHARS}
169 className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
170 />
171
172 {loading ? <DbAiLoading text="Generating formula…" rows={1} /> : null}
173
174 {error ? (
175 <DbAiErrorBanner
176 code={error.code}
177 message={invalidStructure && error.parseError ? `Formula invalid: ${error.parseError}` : error.message}
178 budget={budget}
179 retryable={budget}
180 onRetry={retry}
181 />
182 ) : null}
183
184 {result ? (
185 <div className="space-y-3" data-testid="db-formula-done">
186 <div className="rounded-md border border-border">
187 <div className="flex items-center justify-between border-b border-border px-3 py-1.5 text-[11px] text-muted-foreground">
188 <span className="font-medium">Formula</span>
189 <span className="rounded bg-muted px-1.5 py-0.5 text-[10px]">returns {result.returnType}</span>
190 </div>
191 <code data-testid="db-formula-expression" className="block break-all px-3 py-2 font-mono text-xs">
192 {result.expression}
193 </code>
194 {result.explanation ? <p className="border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">{result.explanation}</p> : null}
195 </div>
196
197 {result.samples.length > 0 ? (
198 <div className="overflow-hidden rounded-md border border-border">
199 <div className="border-b border-border bg-muted/30 px-3 py-1.5 text-[11px] font-medium text-muted-foreground">
200 Sample values ({result.samples.length} row{result.samples.length === 1 ? "" : "s"})
201 </div>
202 <table data-testid="db-formula-table" className="w-full text-left text-xs">
203 <thead>
204 <tr className="border-b border-border text-[10px] uppercase tracking-wide text-muted-foreground">
205 <th className="px-3 py-1 font-medium">Record</th>
206 <th className="px-3 py-1 font-medium">{formulaProp?.name ?? "Formula"}</th>
207 </tr>
208 </thead>
209 <tbody>
210 {result.samples.map((s) => {
211 const rec = recordById.current.get(s.recordId);
212 return (
213 <tr key={s.recordId} className="border-b border-border last:border-0">
214 <td className="max-w-[16rem] truncate px-3 py-1.5">{rec?.title?.trim() || s.recordId.slice(0, 8)}</td>
215 <td className="px-3 py-1.5">
216 {s.error ? (
217 <span className="text-amber-600 dark:text-amber-400" title={s.error}>
218 error: {s.error}
219 </span>
220 ) : (
221 displayStoredValue(s.value)
222 )}
223 </td>
224 </tr>
225 );
226 })}
227 </tbody>
228 </table>
229 </div>
230 ) : (
231 <p className="text-[11px] text-muted-foreground">No records yet — the column will compute as rows are added.</p>
232 )}
233
234 <div
235 role="status"
236 className="flex items-center gap-2 rounded-md border border-green-300 bg-green-50 px-3 py-2 text-xs text-green-900 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200"
237 >
238 <CheckCheck className="size-3.5 shrink-0" aria-hidden />
239 <span className="min-w-0 flex-1">
240 The formula column “{formulaProp?.name ?? "Formula"}” is now in the database.
241 </span>
242 </div>
243 </div>
244 ) : null}
245 </DbAiDialog>
246 );
247}
248