Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)

- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
This commit is contained in:
2026-10-04 20:57:31 +05:30
parent d3b5fbb1b8
commit 2ffbf0f66c
24 changed files with 2855 additions and 11 deletions
+591
View File
@@ -0,0 +1,591 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
ComboBox,
FileUploaderButton,
InlineLoading,
InlineNotification,
PasswordInput,
RadioButton,
RadioButtonGroup,
TextArea,
TextInput,
Tag,
Tile,
Toggle,
} from "@carbon/react";
import { Save } from "@carbon/icons-react";
import { api } from "../lib/api";
import type { ErpnextConfig, ErpnextConfigInput, ErpnextConnectionTest, ErpnextOptionItem, ErpnextOptions } from "../lib/erpnext";
import {
OPTION_LIST_LABELS,
UOM_UNITS,
connectionProblems,
emptyOptions,
hasProblems,
mirrorSupported,
optionErrors,
optionText,
sortWarnings,
toInput,
validateConfigForm,
versionsLine,
warningSeverity,
withStoredValue,
type FormProblems,
} from "../lib/erpnextUi";
import { useToast } from "./ToastProvider";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
interface PickProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
/** The loaded list; an empty list means "not loaded", and the field takes typed text instead. */
items: ErpnextOptionItem[];
/** Why this list is empty although the lists were loaded (a load error for this list). */
error?: string;
/** The control is switched off, with the reason shown under it. */
disabledReason?: string;
helper?: string;
}
/** A searchable dropdown over a live list, or a text box while the list has not been loaded. */
function PickField({ id, label, value, onChange, items, error, disabledReason, helper }: PickProps) {
const options = useMemo(() => withStoredValue(items, value), [items, value]);
const note = disabledReason ?? error ?? helper;
if (items.length === 0 || disabledReason) {
return (
<TextInput
id={id}
labelText={label}
value={value}
disabled={Boolean(disabledReason)}
invalid={Boolean(error) && !disabledReason}
invalidText={error}
helperText={error ? undefined : note}
onChange={(e) => onChange(e.target.value)}
/>
);
}
return (
<ComboBox
id={id}
titleText={label}
items={options}
itemToString={(i: ErpnextOptionItem | null) => (i ? optionText(i) : "")}
selectedItem={options.find((o) => o.name === value) ?? null}
onChange={({ selectedItem }: { selectedItem?: ErpnextOptionItem | null }) => onChange(selectedItem?.name ?? "")}
shouldFilterItem={({ item, inputValue }: { item: ErpnextOptionItem; inputValue: string | null }) =>
!inputValue || optionText(item).toLowerCase().includes(inputValue.toLowerCase())
}
helperText={helper}
placeholder="Search or choose"
/>
);
}
function Section({ title, children, note }: { title: string; children: React.ReactNode; note?: string }) {
return (
<section className="voiced-erp__section" aria-label={title}>
<h3>{title}</h3>
{note ? <p className="voiced-muted">{note}</p> : null}
<div className="voiced-erp__fields">{children}</div>
</section>
);
}
/** Settings, Integrations, ERPNext. It has its own Save: the page-level Save settings button does not cover it. */
export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
const toast = useToast();
const [saved, setSaved] = useState<ErpnextConfig | null>(null);
const [form, setForm] = useState<ErpnextConfigInput | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
const [test, setTest] = useState<ErpnextConnectionTest | null>(null);
/** A test result from this session that is not saved yet. */
const [testUnsaved, setTestUnsaved] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [options, setOptions] = useState<ErpnextOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [saving, setSaving] = useState(false);
const [showProblems, setShowProblems] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const cfg = await api.erpnextGetConfig();
setSaved(cfg);
setForm(toInput(cfg));
setTest(cfg.lastDetectResult);
setTestUnsaved(false);
setLoadError(null);
} catch (e) {
setLoadError(String(e));
}
}, []);
useEffect(() => {
if (active && form === null) void load();
// Reload only on first show; edits must survive switching tabs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
if (loadError) {
return <InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the ERPNext settings" subtitle={loadError} />;
}
if (!form || !saved) return <InlineLoading description="Loading ERPNext settings" />;
const set = <K extends keyof ErpnextConfigInput>(key: K, value: ErpnextConfigInput[K]) => setForm((f) => (f ? { ...f, [key]: value } : f));
const secretStored = saved.apiSecretSet;
const secretShown = secretStored && !replacing && !form.clearSecret;
const problems: FormProblems = showProblems ? validateConfigForm(form, secretStored, test) : {};
const connProblems = connectionProblems(form, secretStored);
const canConnect = !hasProblems(connProblems);
const optErrors = optionErrors(options);
const opts = options ?? emptyOptions();
const noCompany = !form.company.trim();
const companyReason = noCompany ? "Pick a company first." : undefined;
const seriesMode = form.namingMode === "series";
const mirrorOk = mirrorSupported(test);
const loadOptions = async (values: ErpnextConfigInput) => {
setLoadingOptions(true);
try {
setOptions(await api.erpnextLoadOptions(values));
} catch (e) {
setOptions(null);
toast.error("Could not load the ERPNext lists", String(e));
} finally {
setLoadingOptions(false);
}
};
const runTest = async () => {
setShowProblems(true);
if (!canConnect) return;
setTesting(true);
setTestError(null);
try {
const result = await api.erpnextTestConnection(form);
setTest(result);
setTestUnsaved(true);
let next = form;
// A site that keeps no posted name cannot mirror the Voiced number.
if (!result.features.v2Naming && form.namingMode === "mirror") {
next = { ...form, namingMode: "series" };
setForm(next);
}
await loadOptions(next);
} catch (e) {
setTestError(String(e));
} finally {
setTesting(false);
}
};
const changeCompany = (company: string) => {
const next = { ...form, company };
setForm(next);
// Accounts, addresses and cost centers belong to the company, so the lists are reloaded for it.
if (options && canConnect) void loadOptions(next);
};
const save = async () => {
setShowProblems(true);
setSaveError(null);
if (hasProblems(validateConfigForm(form, secretStored, test))) return;
setSaving(true);
try {
const cfg = await api.erpnextSaveConfig({ ...form, ...(testUnsaved && test ? { lastDetectResult: test } : {}) });
setSaved(cfg);
setForm(toInput(cfg));
setReplacing(false);
setTest(cfg.lastDetectResult ?? test);
setTestUnsaved(false);
setShowProblems(false);
toast.success("ERPNext settings saved");
} catch (e) {
const text = String(e);
setSaveError(text);
toast.error("Could not save the ERPNext settings", text);
} finally {
setSaving(false);
}
};
const readPem = async (file: File | undefined) => {
if (!file) return;
try {
set("extraCaPem", (await file.text()).trim());
} catch (e) {
toast.error("Could not read the certificate file", String(e));
}
};
const secretInvalid = problems.apiSecret ?? (saveError && /secret again/i.test(saveError) ? saveError : undefined);
return (
<div className="voiced-stack voiced-erp">
<div>
<h2 className="voiced-detail__h2">ERPNext</h2>
<p>
Send issued invoices and payments to your ERPNext site as Sales Invoices. Everything is sent from this computer; ERPNext is never
needed to issue an invoice.
</p>
<p className="voiced-muted">
Create a dedicated ERPNext user for Voiced with only these permissions: create and submit Sales Invoice, read on the master data
(Customer, Item, Address, Account, Company), and write on File. Use that user&rsquo;s API key and secret below. An address must be
https://; plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test hosts.
</p>
</div>
<Section title="Connection">
<TextInput
id="erp-baseUrl"
labelText="Site address"
placeholder="https://erp.example.com"
value={form.baseUrl}
invalid={Boolean(problems.baseUrl)}
invalidText={problems.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
/>
<TextInput
id="erp-apiKey"
labelText="API key"
autoComplete="off"
value={form.apiKey}
invalid={Boolean(problems.apiKey)}
invalidText={problems.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
/>
{secretShown ? (
<div className="voiced-erp__secret">
<p className="voiced-erp__label" id="erp-secret-label">
API secret
</p>
<p aria-labelledby="erp-secret-label">
<Tag type="green" size="sm">
set
</Tag>{" "}
<span className="voiced-muted">The secret is stored on this computer and is never shown again.</span>
</p>
<div className="voiced-header-action">
<Button kind="tertiary" size="sm" onClick={() => setReplacing(true)}>
Replace
</Button>
<Button kind="ghost" size="sm" onClick={() => set("clearSecret", true)}>
Clear
</Button>
</div>
</div>
) : (
<div>
<PasswordInput
id="erp-apiSecret"
labelText="API secret"
autoComplete="new-password"
value={form.apiSecret}
invalid={Boolean(secretInvalid)}
invalidText={secretInvalid}
helperText={form.clearSecret ? "The stored secret is removed when you save, unless you type a new one." : undefined}
onChange={(e) => set("apiSecret", e.target.value)}
/>
{secretStored ? (
<Button
kind="ghost"
size="sm"
onClick={() => {
setReplacing(false);
setForm((f) => (f ? { ...f, apiSecret: "", clearSecret: false } : f));
}}
>
Keep the stored secret
</Button>
) : null}
</div>
)}
<div>
<TextArea
id="erp-extraCa"
labelText="Extra CA certificate (optional, PEM)"
helperText="Only for a self-hosted site whose certificate is not trusted by this computer."
rows={3}
value={form.extraCaPem}
onChange={(e) => set("extraCaPem", e.target.value)}
/>
<FileUploaderButton
labelText="Load from file…"
buttonKind="ghost"
size="sm"
accept={[".pem", ".crt", ".cer"]}
disableLabelChanges
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
void readPem(file);
}}
/>
</div>
<div className="voiced-header-action">
<Button kind="secondary" size="md" onClick={() => void runTest()} disabled={testing || loadingOptions}>
{testing ? "Testing…" : "Test connection"}
</Button>
{testing || loadingOptions ? <InlineLoading description={testing ? "Contacting ERPNext" : "Loading lists"} /> : null}
{!canConnect && showProblems ? <span className="voiced-muted">Fix the address, key and secret first.</span> : null}
</div>
{testError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Connection failed" subtitle={testError} /> : null}
{test ? (
<Tile className="voiced-erp__result" aria-label="Connection test result">
<p>
<strong>Connected as {test.user || "an unknown user"}</strong>{" "}
<Tag type={test.indiaCompliance ? "green" : "warm-gray"} size="sm">
{test.indiaCompliance ? "India Compliance detected" : "India Compliance not detected"}
</Tag>
</p>
<p className="voiced-muted">{versionsLine(test)}</p>
{testUnsaved ? (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Save to keep this result"
subtitle="Sending invoices needs a saved connection test."
/>
) : (
<p className="voiced-muted">Last saved test result.</p>
)}
{sortWarnings(test.warnings).map((w, i) => {
const severity = warningSeverity(w.code);
return (
<InlineNotification
key={`${w.code}-${i}`}
kind={severity}
lowContrast
hideCloseButton
title={SEVERITY_TITLE[severity]}
subtitle={w.message}
/>
);
})}
{test.warnings.length === 0 ? <p className="voiced-muted">No problems found with the current settings.</p> : null}
<p className="voiced-muted">Test again after changing the mappings below to refresh these warnings.</p>
</Tile>
) : null}
</Section>
<Section
title="Company and accounts"
note={options ? "Choose from what your ERPNext site has." : "Run Test connection to choose from your site's lists; until then, type the exact names."}
>
{options && options.errors.length > 0 ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="Some lists could not be loaded"
subtitle={options.errors.map((e) => `${OPTION_LIST_LABELS[e.list] ?? e.list}: ${e.message}`).join(" ")}
/>
) : null}
<PickField
id="erp-company"
label="Company"
value={form.company}
onChange={changeCompany}
items={opts.companies}
error={optErrors.get("companies")}
/>
<PickField
id="erp-companyAddress"
label="Company address"
value={form.companyAddress}
onChange={(v) => set("companyAddress", v)}
items={opts.companyAddresses}
error={optErrors.get("companyAddresses")}
disabledReason={companyReason}
helper="Required by India Compliance on every invoice."
/>
<PickField
id="erp-incomeAccount"
label="Income account"
value={form.incomeAccount}
onChange={(v) => set("incomeAccount", v)}
items={opts.incomeAccounts}
error={optErrors.get("incomeAccounts")}
disabledReason={companyReason}
/>
<PickField
id="erp-costCenter"
label="Cost center"
value={form.costCenter}
onChange={(v) => set("costCenter", v)}
items={opts.costCenters}
error={optErrors.get("costCenters")}
disabledReason={companyReason}
/>
{(
[
["cgstAccount", "CGST account"],
["sgstAccount", "SGST account"],
["utgstAccount", "UTGST account"],
["igstAccount", "IGST account"],
] as const
).map(([key, label]) => (
<PickField
key={key}
id={`erp-${key}`}
label={label}
value={form[key]}
onChange={(v) => set(key, v)}
items={opts.taxAccounts}
error={optErrors.get("taxAccounts")}
disabledReason={companyReason}
helper={key === "utgstAccount" ? "Only needed when the supplier is in a union territory without a legislature." : "Must be the account set in GST Settings."}
/>
))}
<TextInput
id="erp-paymentBank"
labelText="Payment bank account"
helperText="The bank or cash account that receives payments, e.g. HDFC Bank - AC. Needed to send payments."
value={form.paymentBankAccount}
onChange={(e) => set("paymentBankAccount", e.target.value)}
/>
<TextInput
id="erp-tdsAccount"
labelText="TDS account"
helperText="The account that holds tax deducted by clients. Needed only for payments with TDS."
value={form.tdsAccount}
onChange={(e) => set("tdsAccount", e.target.value)}
/>
</Section>
<Section title="Customers and items">
<TextInput
id="erp-defaultItem"
labelText="Default item code (optional)"
helperText="Used for invoice lines without an item code of their own; a line with none is sent as a description-only row."
value={form.defaultItemCode}
onChange={(e) => set("defaultItemCode", e.target.value)}
/>
<PickField
id="erp-customerGroup"
label="Customer group for new customers"
value={form.customerGroup}
onChange={(v) => set("customerGroup", v)}
items={opts.customerGroups}
error={optErrors.get("customerGroups")}
/>
<PickField
id="erp-territory"
label="Territory for new customers"
value={form.territory}
onChange={(v) => set("territory", v)}
items={opts.territories}
error={optErrors.get("territories")}
/>
<PickField
id="erp-priceList"
label="Selling price list"
value={form.sellingPriceList}
onChange={(v) => set("sellingPriceList", v)}
items={opts.priceLists}
error={optErrors.get("priceLists")}
/>
<fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
{UOM_UNITS.map((unit) => (
<PickField
key={unit}
id={`erp-uom-${unit}`}
label={`Voiced ${unit}`}
value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms}
error={optErrors.get("uoms")}
/>
))}
</fieldset>
</Section>
<Section title="Invoice numbers">
<RadioButtonGroup
legendText="Numbering in ERPNext"
name="erp-namingMode"
orientation="vertical"
valueSelected={form.namingMode}
onChange={(value) => set("namingMode", value === "series" ? "series" : "mirror")}
>
<RadioButton
id="erp-nm-mirror"
value="mirror"
labelText="Mirror the Voiced number (recommended)"
disabled={!mirrorOk}
/>
<RadioButton id="erp-nm-series" value="series" labelText="Use an ERPNext naming series" />
</RadioButtonGroup>
<p className="voiced-muted">
{mirrorOk
? "Mirror keeps the number printed on the PDF as the ERPNext invoice name, so GST returns match. Needs Frappe 15.73 or later."
: "Mirror is unavailable: this ERPNext version replaces a posted invoice number. The Voiced number is stored in the remarks instead."}
</p>
{problems.namingMode ? <p className="voiced-erp__error">{problems.namingMode}</p> : null}
<PickField
id="erp-namingSeries"
label="Naming series"
value={form.namingSeries}
onChange={(v) => set("namingSeries", v)}
items={opts.namingSeries}
error={problems.namingSeries ?? optErrors.get("namingSeries")}
disabledReason={seriesMode ? undefined : "Only used with an ERPNext naming series."}
/>
</Section>
<Section title="Sending">
<Toggle
id="erp-submit"
labelText="Submit invoices when sending"
labelA="Off: drafts for review"
labelB="On: submitted"
toggled={form.submitOnPush}
onToggle={(v) => set("submitOnPush", v)}
/>
<Toggle
id="erp-attach"
labelText="Attach the PDF"
labelA="Off"
labelB="On"
toggled={form.attachPdf}
onToggle={(v) => set("attachPdf", v)}
/>
<Toggle
id="erp-auto"
labelText="Send automatically when an invoice is issued"
labelA="Off"
labelB="On"
toggled={form.autoPushOnIssue}
onToggle={(v) => set("autoPushOnIssue", v)}
/>
<Toggle
id="erp-customers"
labelText="Create customers that are missing in ERPNext"
labelA="Off"
labelB="On"
toggled={form.createMissingCustomers}
onToggle={(v) => set("createMissingCustomers", v)}
/>
</Section>
{saveError && !secretInvalid ? <InlineNotification kind="error" lowContrast hideCloseButton title="Not saved" subtitle={saveError} /> : null}
<div>
<Button renderIcon={Save} onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : "Save ERPNext settings"}
</Button>
<p className="voiced-muted">This tab has its own Save button; Save settings at the bottom of the page does not cover it.</p>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "react";
import { Button, InlineLoading, Modal, RadioButton, RadioButtonGroup, Tag } from "@carbon/react";
import { api } from "../lib/api";
import type { ErpnextPushResult } from "../lib/erpnext";
import { describePush, failedPush, summarizeBatch } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus";
export interface SendTarget {
id: number;
number: string;
}
interface Props {
open: boolean;
invoices: SendTarget[];
/** Cancelled or draft invoices that were left out of the selection. */
skipped?: number;
/** The "submit on push" setting; the user still chooses explicitly. */
defaultSubmit: boolean;
onClose: () => void;
/** Called once when a send has finished, so the list can refresh its statuses. */
onFinished: (results: ErpnextPushResult[]) => void;
}
type Phase = "confirm" | "running" | "done";
const TAG = { success: "green", info: "blue", warning: "warm-gray", error: "red" } as const;
/** Confirm step (draft or submit), then a sequential send with progress, then per-invoice results. A failure never stops the rest. */
export default function SendToErpnextModal({ open, invoices, skipped = 0, defaultSubmit, onClose, onFinished }: Props) {
const [submit, setSubmit] = useState(defaultSubmit);
const [phase, setPhase] = useState<Phase>("confirm");
const [results, setResults] = useState<ErpnextPushResult[]>([]);
const alive = useRef(true);
useReturnFocus(open);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
if (!open) return;
setSubmit(defaultSubmit);
setPhase("confirm");
setResults([]);
}, [open, defaultSubmit]);
const run = async () => {
setPhase("running");
const out: ErpnextPushResult[] = [];
for (const inv of invoices) {
let result: ErpnextPushResult;
try {
result = await api.erpnextPushInvoice(inv.id, submit);
} catch (e) {
// Not set up, or the command itself failed: this row fails, the others still go.
result = failedPush(inv.id, inv.number, e);
}
out.push(result);
if (alive.current) setResults([...out]);
}
if (alive.current) setPhase("done");
onFinished(out);
};
const count = invoices.length;
const noun = count === 1 ? "invoice" : "invoices";
if (phase === "confirm") {
return (
<Modal
open={open}
modalHeading={count === 1 ? `Send ${invoices[0]?.number} to ERPNext` : `Send ${count} invoices to ERPNext`}
primaryButtonText={`Send ${count} ${noun}`}
secondaryButtonText="Cancel"
primaryButtonDisabled={count === 0}
onRequestClose={onClose}
onRequestSubmit={() => void run()}
>
<div className="voiced-stack">
<RadioButtonGroup legendText="What should ERPNext do with it?" name="erp-send-mode" orientation="vertical" valueSelected={submit ? "submit" : "draft"} onChange={(v) => setSubmit(v === "submit")}>
<RadioButton id="erp-send-draft" value="draft" labelText="Save as a draft for review" />
<RadioButton id="erp-send-submit" value="submit" labelText="Submit it (posts to the books)" />
</RadioButtonGroup>
<p className="voiced-muted">
An invoice that is already in ERPNext is never overwritten or created twice.
{skipped > 0 ? ` ${skipped} cancelled ${skipped === 1 ? "invoice is" : "invoices are"} left out: cancelled invoices are never sent.` : ""}
</p>
</div>
</Modal>
);
}
const summary = phase === "done" ? summarizeBatch(results) : null;
return (
<Modal
open={open}
passiveModal
modalHeading={phase === "running" ? "Sending to ERPNext" : "Send finished"}
preventCloseOnClickOutside
onRequestClose={() => {
if (phase === "done") onClose();
}}
>
<div className="voiced-stack">
{phase === "running" ? (
<InlineLoading description={`Sent ${results.length} of ${count}…`} />
) : (
<p role="status">
<strong>{summary?.title}</strong>
</p>
)}
<ul className="voiced-erp__results" aria-label="Results">
{results.map((r) => {
const note = describePush(r);
return (
<li key={r.invoiceId}>
<Tag type={TAG[note.kind]} size="sm">
{r.ok ? (r.noOp ? "Up to date" : r.remoteDocstatus === 1 ? "Submitted" : "Draft") : r.status === "conflict" ? "Conflict" : r.status === "refused" ? "Not sent" : "Error"}
</Tag>{" "}
<strong>{r.number || `#${r.invoiceId}`}</strong>
{r.remoteName && r.ok ? <span className="voiced-muted"> {r.remoteName}</span> : null}
{!r.ok && r.error ? <div className="voiced-muted">{r.error}</div> : null}
{r.ok && r.warnings.length > 0 ? <div className="voiced-muted">{r.warnings.join(" ")}</div> : null}
</li>
);
})}
</ul>
{phase === "done" ? (
<Button kind="primary" size="md" onClick={onClose}>
Close
</Button>
) : null}
</div>
</Modal>
);
}