Skip to content

Commit fbedf44

Browse files
committed
feat(studio): rate sheet editor + CSV import (EBE-105)
RateSheetsScreen was list/detail only. Now full management: - Editor: name + carrier picker (from references) + a services rate table (add/remove rows: service_code, service_name, currency, cost, transit_days). - CSV import: paste service rows (header-aware) → parsed into services. - Create/update/delete via GraphQL create/update/delete_rate_sheet; RateSheet type + list query extended with services { service_code service_name currency cost min_weight max_weight weight_unit transit_days }. - Live-verified create-with-services + read-back + delete round-trip. parity spec: row→editor (services table) + create-with-CSV-import (2). tsc/build clean.
1 parent b002c83 commit fbedf44

4 files changed

Lines changed: 264 additions & 28 deletions

File tree

apps/studio/src/lib/karrio/hooks/resources.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export function useWorkflows() {
180180
// === Rate sheets (GraphQL) ==================================================
181181
const RATE_SHEETS_QUERY = `query { rate_sheets { edges { node {
182182
id name slug carrier_name
183+
services { id service_code service_name currency cost min_weight max_weight weight_unit transit_days }
183184
} } } }`;
184185

185186
export function useRateSheets() {
@@ -191,6 +192,36 @@ export function useRateSheets() {
191192
});
192193
}
193194

195+
// Rate sheet create/update/delete (GraphQL). services is a list of service levels.
196+
const CREATE_RATE_SHEET = `mutation($input: CreateRateSheetMutationInput!) {
197+
create_rate_sheet(input: $input) { rate_sheet { id slug } errors { field messages } }
198+
}`;
199+
const UPDATE_RATE_SHEET = `mutation($input: UpdateRateSheetMutationInput!) {
200+
update_rate_sheet(input: $input) { rate_sheet { id slug } errors { field messages } }
201+
}`;
202+
const DELETE_RATE_SHEET = `mutation($input: DeleteMutationInput!) { delete_rate_sheet(input: $input) { id } }`;
203+
204+
export function useSaveRateSheet() {
205+
const ctx = useKarrioCtx();
206+
const qc = useQueryClient();
207+
return useMutation({
208+
mutationFn: (vars: { id?: string; data: Record<string, unknown> }) =>
209+
graphql(ctx, vars.id ? UPDATE_RATE_SHEET : CREATE_RATE_SHEET, {
210+
input: vars.id ? { id: vars.id, ...vars.data } : vars.data,
211+
}),
212+
onSuccess: () => qc.invalidateQueries({ queryKey: ["rate-sheets"] }),
213+
});
214+
}
215+
216+
export function useDeleteRateSheet() {
217+
const ctx = useKarrioCtx();
218+
const qc = useQueryClient();
219+
return useMutation({
220+
mutationFn: (id: string) => graphql(ctx, DELETE_RATE_SHEET, { input: { id } }),
221+
onSuccess: () => qc.invalidateQueries({ queryKey: ["rate-sheets"] }),
222+
});
223+
}
224+
194225
// === Mutations: address / parcel / product (GraphQL) ========================
195226
const CREATE_ADDRESS = `mutation($input: CreateAddressInput!) {
196227
create_address(input: $input) {

apps/studio/src/lib/karrio/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,11 +307,23 @@ export type Workflow = {
307307
// RateSheet: live OSS schema fields are id, name, slug, carrier_name.
308308
// `services_count` and `is_system` do not exist on the OSS RateSheetType and
309309
// will always be undefined when fetched from OSS; screens degrade gracefully.
310+
export type RateSheetService = {
311+
id?: string;
312+
service_code: string;
313+
service_name: string;
314+
currency?: string;
315+
cost?: number;
316+
min_weight?: number;
317+
max_weight?: number;
318+
weight_unit?: string;
319+
transit_days?: number;
320+
};
310321
export type RateSheet = {
311322
id: string;
312323
name: string;
313324
slug?: string;
314325
carrier_name?: string;
326+
services?: RateSheetService[];
315327
/** OSS: always undefined — not exposed by the OSS GraphQL schema */
316328
services_count?: number;
317329
/** OSS: always undefined — not exposed by the OSS GraphQL schema */
Lines changed: 201 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,49 @@
1-
// RateSheetsScreen.tsx — Govern › Rate sheets (dashboard parity).
1+
// RateSheetsScreen.tsx — Govern › Rate sheets. Full management: list + editor
2+
// (name, carrier, services rate table) + CSV import. (EBE-105)
23
import { useMemo, useState } from "react";
34
import { CARRIERS, CarrierLogo } from "~/components/ui/CarrierLogo";
45
import { Icon } from "~/components/ui/icons";
56
import { PageHeader, StateRow, TableFooter } from "~/components/ui/primitives";
6-
import { KV, KVGrid, Section } from "~/components/ui/detail";
7-
import { Sheet } from "~/components/ui/Sheet";
8-
import { useRateSheets } from "~/lib/karrio/hooks";
7+
import { Sheet, Field } from "~/components/ui/Sheet";
8+
import { useRateSheets, useSaveRateSheet, useDeleteRateSheet } from "~/lib/karrio/hooks";
9+
import { useReferences } from "~/lib/karrio/references";
910
import { carrierKey } from "~/lib/karrio/display";
10-
import type { RateSheet } from "~/lib/karrio/types";
11+
import type { RateSheet, RateSheetService } from "~/lib/karrio/types";
12+
13+
type EditorState = "closed" | "create" | { edit: RateSheet };
1114

1215
export function RateSheetsScreen() {
13-
const [preview, setPreview] = useState<RateSheet | null>(null);
16+
const [editor, setEditor] = useState<EditorState>("closed");
1417
const { data, isLoading, isError, error } = useRateSheets();
1518
const rows = useMemo(() => data ?? [], [data]);
1619

1720
return (
1821
<div className="page" data-testid="screen-ratesheets">
19-
<PageHeader title="Rate sheets" actions={<button className="btn btn-primary"><Icon.Plus size={14} /> New rate sheet</button>} />
22+
<PageHeader
23+
title="Rate sheets"
24+
actions={
25+
<button className="btn btn-primary" onClick={() => setEditor("create")} data-testid="ratesheet-create">
26+
<Icon.Plus size={14} /> New rate sheet
27+
</button>
28+
}
29+
/>
2030
<div className="card card-scroll">
2131
<table className="table">
22-
<thead><tr><th>Name</th><th>Carrier</th><th>Services</th><th>Type</th><th className="actions-cell" /></tr></thead>
32+
<thead><tr><th>Name</th><th>Carrier</th><th>Services</th><th className="actions-cell" /></tr></thead>
2333
<tbody>
24-
{isLoading && <StateRow colSpan={5} kind="loading" message="Loading rate sheets…" />}
25-
{isError && !isLoading && <StateRow colSpan={5} kind="error" message={(error as Error)?.message ?? "Failed to load"} />}
26-
{!isLoading && !isError && rows.length === 0 && <StateRow colSpan={5} kind="empty" message="No rate sheets yet." />}
34+
{isLoading && <StateRow colSpan={4} kind="loading" message="Loading rate sheets…" />}
35+
{isError && !isLoading && <StateRow colSpan={4} kind="error" message={(error as Error)?.message ?? "Failed to load"} />}
36+
{!isLoading && !isError && rows.length === 0 && <StateRow colSpan={4} kind="empty" message="No rate sheets yet." />}
2737
{rows.map((rs) => (
28-
<tr key={rs.id} onClick={() => setPreview(rs)} data-testid={`ratesheet-row-${rs.id}`}>
38+
<tr key={rs.id} onClick={() => setEditor({ edit: rs })} data-testid={`ratesheet-row-${rs.id}`}>
2939
<td className="recipient-name">{rs.name}</td>
3040
<td>
3141
<div className="svc-cell">
3242
<CarrierLogo carrier={carrierKey(rs.carrier_name)} size="sm" />
3343
<span>{CARRIERS[carrierKey(rs.carrier_name)]?.name ?? rs.carrier_name ?? "—"}</span>
3444
</div>
3545
</td>
36-
<td>{rs.services_count ?? "—"}</td>
37-
<td><span className="tag">{rs.is_system ? "system" : "custom"}</span></td>
46+
<td>{rs.services?.length ?? rs.services_count ?? 0}</td>
3847
<td className="actions-cell" onClick={(e) => e.stopPropagation()}><span className="icon-action"><Icon.Dots size={14} /></span></td>
3948
</tr>
4049
))}
@@ -43,20 +52,185 @@ export function RateSheetsScreen() {
4352
<TableFooter shown={rows.length} total={rows.length} noun="rate sheets" />
4453
</div>
4554

46-
{preview && (
47-
<Sheet open onClose={() => setPreview(null)} size="md" crumb="Rate sheets" title={preview.name} id={preview.id}>
48-
<div className="sheet-body-pad" data-testid="ratesheet-sheet-body">
49-
<Section title="Details">
50-
<KVGrid>
51-
<KV label="Name">{preview.name}</KV>
52-
<KV label="Carrier">{preview.carrier_name ?? "—"}</KV>
53-
<KV label="Services">{preview.services_count ?? "—"}</KV>
54-
<KV label="Type">{preview.is_system ? "System" : "Custom"}</KV>
55-
</KVGrid>
56-
</Section>
57-
</div>
58-
</Sheet>
55+
{editor !== "closed" && (
56+
<RateSheetEditor
57+
key={editor === "create" ? "create" : editor.edit.id}
58+
initial={editor === "create" ? undefined : editor.edit}
59+
onClose={() => setEditor("closed")}
60+
/>
5961
)}
6062
</div>
6163
);
6264
}
65+
66+
const CSV_COLUMNS = ["service_code", "service_name", "currency", "cost", "min_weight", "max_weight", "transit_days"] as const;
67+
const NUMERIC = new Set(["cost", "min_weight", "max_weight", "transit_days"]);
68+
69+
// Parse a simple CSV (with or without a header row) into service rows.
70+
function parseCsv(text: string): RateSheetService[] {
71+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
72+
if (lines.length === 0) return [];
73+
let header = CSV_COLUMNS as readonly string[];
74+
let start = 0;
75+
if (lines[0].toLowerCase().includes("service_code")) {
76+
header = lines[0].split(",").map((h) => h.trim());
77+
start = 1;
78+
}
79+
const out: RateSheetService[] = [];
80+
for (let i = start; i < lines.length; i++) {
81+
const cells = lines[i].split(",").map((c) => c.trim());
82+
const row: Record<string, unknown> = {};
83+
header.forEach((h, idx) => {
84+
const v = cells[idx];
85+
if (v === undefined || v === "") return;
86+
row[h] = NUMERIC.has(h) ? Number(v) : v;
87+
});
88+
if (row.service_code || row.service_name) {
89+
out.push({
90+
service_code: String(row.service_code ?? `svc_${i}`),
91+
service_name: String(row.service_name ?? row.service_code ?? `Service ${i}`),
92+
currency: (row.currency as string) ?? "USD",
93+
cost: row.cost as number | undefined,
94+
min_weight: row.min_weight as number | undefined,
95+
max_weight: row.max_weight as number | undefined,
96+
transit_days: row.transit_days as number | undefined,
97+
});
98+
}
99+
}
100+
return out;
101+
}
102+
103+
function RateSheetEditor({ initial, onClose }: { initial?: RateSheet; onClose: () => void }) {
104+
const { data: refs } = useReferences();
105+
const carriers = useMemo<[string, string][]>(
106+
() => Object.entries(refs?.carriers ?? {}).sort((a, b) => a[1].localeCompare(b[1])),
107+
[refs],
108+
);
109+
110+
const [name, setName] = useState(initial?.name ?? "");
111+
const [carrier, setCarrier] = useState(initial?.carrier_name ?? "");
112+
const [services, setServices] = useState<RateSheetService[]>(initial?.services ?? []);
113+
const [csv, setCsv] = useState("");
114+
const [err, setErr] = useState<string | null>(null);
115+
116+
const save = useSaveRateSheet();
117+
const del = useDeleteRateSheet();
118+
119+
const addService = () =>
120+
setServices((s) => [...s, { service_code: "", service_name: "", currency: "USD" }]);
121+
const updateService = (i: number, patch: Partial<RateSheetService>) =>
122+
setServices((s) => s.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
123+
const removeService = (i: number) => setServices((s) => s.filter((_, idx) => idx !== i));
124+
125+
const importCsv = () => {
126+
const parsed = parseCsv(csv);
127+
if (parsed.length === 0) return setErr("No service rows found in the CSV.");
128+
setServices((s) => [...s, ...parsed]);
129+
setCsv("");
130+
setErr(null);
131+
};
132+
133+
const onSubmit = async (e: React.FormEvent) => {
134+
e.preventDefault();
135+
setErr(null);
136+
if (!name.trim()) return setErr("A name is required.");
137+
if (!carrier) return setErr("Select a carrier.");
138+
const valid = services.filter((s) => s.service_code.trim() && s.service_name.trim());
139+
if (valid.length === 0) return setErr("Add at least one service (code + name).");
140+
try {
141+
await save.mutateAsync({
142+
id: initial?.id,
143+
data: {
144+
name: name.trim(),
145+
carrier_name: carrier,
146+
services: valid.map((s) => ({
147+
service_code: s.service_code.trim(),
148+
service_name: s.service_name.trim(),
149+
currency: s.currency || "USD",
150+
cost: s.cost,
151+
min_weight: s.min_weight,
152+
max_weight: s.max_weight,
153+
weight_unit: s.weight_unit || "KG",
154+
transit_days: s.transit_days,
155+
})),
156+
},
157+
});
158+
onClose();
159+
} catch (e2) {
160+
setErr((e2 as Error)?.message ?? "Could not save the rate sheet.");
161+
}
162+
};
163+
164+
const onDelete = async () => {
165+
if (!initial) return;
166+
try {
167+
await del.mutateAsync(initial.id);
168+
onClose();
169+
} catch {
170+
setErr("Could not delete the rate sheet.");
171+
}
172+
};
173+
174+
return (
175+
<Sheet
176+
open onClose={onClose} size="lg" crumb="Rate sheets"
177+
title={initial ? "Edit rate sheet" : "New rate sheet"} id={initial?.id}
178+
footer={
179+
<>
180+
{initial && <button className="btn" onClick={onDelete} disabled={del.isPending} data-testid="ratesheet-delete">Delete</button>}
181+
<div style={{ flex: 1 }} />
182+
<button className="btn" onClick={onClose}>Cancel</button>
183+
<button className="btn btn-primary" form="ratesheet-form" type="submit" disabled={save.isPending} data-testid="ratesheet-save">
184+
{save.isPending ? "Saving…" : "Save rate sheet"}
185+
</button>
186+
</>
187+
}
188+
>
189+
<form id="ratesheet-form" className="sheet-body-pad" onSubmit={onSubmit} data-testid="ratesheet-sheet-body">
190+
<div className="kv-grid">
191+
<Field label="Name *"><input className="field-input" value={name} onChange={(e) => setName(e.target.value)} data-testid="rs-name" /></Field>
192+
<Field label="Carrier *">
193+
<select className="field-input" value={carrier} onChange={(e) => setCarrier(e.target.value)} disabled={!!initial} data-testid="rs-carrier">
194+
<option value="">Select a carrier…</option>
195+
{carriers.map(([id, label]) => <option key={id} value={id}>{label}</option>)}
196+
</select>
197+
</Field>
198+
</div>
199+
200+
<div className="section-head" style={{ display: "flex", alignItems: "center", marginTop: 14 }}>
201+
Services &amp; rates
202+
<button type="button" className="btn btn-sm" style={{ marginLeft: "auto" }} onClick={addService} data-testid="rs-add-service"><Icon.Plus size={12} /> Add service</button>
203+
</div>
204+
<table className="table" data-testid="rs-services">
205+
<thead><tr><th>Code</th><th>Name</th><th>Currency</th><th>Cost</th><th>Transit</th><th /></tr></thead>
206+
<tbody>
207+
{services.length === 0 && <tr><td colSpan={6}><div className="state-row" style={{ fontSize: 12 }}>No services yet — add one or import a CSV below.</div></td></tr>}
208+
{services.map((s, i) => (
209+
<tr key={i} data-testid={`rs-service-${i}`}>
210+
<td><input className="field-input mono" style={{ minWidth: 90 }} value={s.service_code} onChange={(e) => updateService(i, { service_code: e.target.value })} data-testid={`rs-service-code-${i}`} /></td>
211+
<td><input className="field-input" style={{ minWidth: 120 }} value={s.service_name} onChange={(e) => updateService(i, { service_name: e.target.value })} data-testid={`rs-service-name-${i}`} /></td>
212+
<td><input className="field-input" style={{ width: 64 }} value={s.currency ?? ""} onChange={(e) => updateService(i, { currency: e.target.value })} /></td>
213+
<td><input className="field-input" style={{ width: 72 }} type="number" step="0.01" value={s.cost ?? ""} onChange={(e) => updateService(i, { cost: e.target.value === "" ? undefined : Number(e.target.value) })} /></td>
214+
<td><input className="field-input" style={{ width: 56 }} type="number" value={s.transit_days ?? ""} onChange={(e) => updateService(i, { transit_days: e.target.value === "" ? undefined : Number(e.target.value) })} /></td>
215+
<td><button type="button" className="icon-action" onClick={() => removeService(i)} data-testid={`rs-service-remove-${i}`}><Icon.X size={13} /></button></td>
216+
</tr>
217+
))}
218+
</tbody>
219+
</table>
220+
221+
<div className="section-head" style={{ marginTop: 14 }}>Import CSV</div>
222+
<textarea
223+
className="field-input mono"
224+
style={{ width: "100%", height: 90, fontSize: 12 }}
225+
placeholder="service_code,service_name,currency,cost,min_weight,max_weight,transit_days"
226+
value={csv}
227+
onChange={(e) => setCsv(e.target.value)}
228+
data-testid="rs-csv"
229+
/>
230+
<button type="button" className="btn btn-sm" style={{ marginTop: 6 }} onClick={importCsv} data-testid="rs-csv-import"><Icon.Download size={12} /> Import services from CSV</button>
231+
232+
{err && <div className="auth-error" data-testid="ratesheet-form-error">{err}</div>}
233+
</form>
234+
</Sheet>
235+
);
236+
}

packages/e2e/tests/studio/parity.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,30 @@ test.describe("Dashboard parity screens", () => {
6565
await expect(page.getByTestId("workflow-sheet-body")).toBeVisible();
6666
});
6767

68-
test("Rate sheets: list + row → sheet", async ({ page }) => {
68+
test("Rate sheets: list + row → editor", async ({ page }) => {
6969
await page.goto("/ratesheets");
7070
await expect(page.getByTestId("ratesheet-row-rs_1")).toContainText("UPS Negotiated");
7171
await page.getByTestId("ratesheet-row-rs_1").click();
7272
await expect(page.getByTestId("ratesheet-sheet-body")).toBeVisible();
73+
await expect(page.getByTestId("rs-services")).toBeVisible();
74+
});
75+
76+
test("Rate sheets: create with CSV import of services", async ({ page }) => {
77+
await page.route("**/v1/references**", (route) =>
78+
route.fulfill({ status: 200, headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, body: JSON.stringify({ carriers: { ups: "UPS", fedex: "FedEx" }, connection_fields: {} }) }));
79+
await page.goto("/ratesheets");
80+
await expect(page.getByTestId("ratesheet-row-rs_1")).toBeVisible();
81+
await page.getByTestId("ratesheet-create").click();
82+
await expect(page.getByTestId("ratesheet-sheet-body")).toBeVisible();
83+
await page.getByTestId("rs-name").fill("My negotiated rates");
84+
await page.getByTestId("rs-carrier").selectOption("fedex");
85+
// Import services from CSV.
86+
await page.getByTestId("rs-csv").fill("service_code,service_name,currency,cost,transit_days\nfedex_ground,Ground,USD,9.99,3\nfedex_2day,2 Day,USD,18.5,2");
87+
await page.getByTestId("rs-csv-import").click();
88+
await expect(page.getByTestId("rs-service-code-0")).toHaveValue("fedex_ground");
89+
await expect(page.getByTestId("rs-service-code-1")).toHaveValue("fedex_2day");
90+
await page.getByTestId("ratesheet-save").click();
91+
await expect(page.getByTestId("ratesheet-sheet-body")).toHaveCount(0);
7392
});
7493

7594
test("Usage: plan + metrics + trend charts render", async ({ page }) => {

0 commit comments

Comments
 (0)