Add bulk invoice import with templates and refresh import UX
Move invoice import configuration into settings, redesign the import flow with shared components and sample templates, document the demo account in README, and polish upload and button styling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
export const CSV_TEMPLATE_FILENAME = "acme-january-template.csv";
|
||||
export const JSON_TEMPLATE_FILENAME = "invoice-import-template.json";
|
||||
|
||||
/** Matches parseInvoiceCSV column expectations (date, item/description, quantity, rate). */
|
||||
export const CSV_TEMPLATE = `date,item,description,quantity,rate
|
||||
2024-01-15,,API development,8,125.00
|
||||
2024-01-16,Design,Design review and feedback,2,125.00
|
||||
1/17/24,,Documentation,4,125.00`;
|
||||
|
||||
/** Matches parseInvoiceJSON shape (client, issueDate, dueDate, items). */
|
||||
export const JSON_TEMPLATE = JSON.stringify(
|
||||
{
|
||||
invoices: [
|
||||
{
|
||||
name: "January Services",
|
||||
issueDate: "2024-01-31",
|
||||
dueDate: "2024-03-01",
|
||||
client: {
|
||||
name: "Acme Corp",
|
||||
email: "billing@acme.com",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: "2024-01-15",
|
||||
description: "API development",
|
||||
quantity: 8,
|
||||
rate: 125,
|
||||
},
|
||||
{
|
||||
date: "2024-01-16",
|
||||
item: "Design",
|
||||
description: "Design review",
|
||||
quantity: 2,
|
||||
rate: 125,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export function downloadImportTemplate(
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadCsvTemplate() {
|
||||
downloadImportTemplate(CSV_TEMPLATE, CSV_TEMPLATE_FILENAME, "text/csv");
|
||||
}
|
||||
|
||||
export function downloadJsonTemplate() {
|
||||
downloadImportTemplate(
|
||||
JSON_TEMPLATE,
|
||||
JSON_TEMPLATE_FILENAME,
|
||||
"application/json",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
export type ImportFormat = "csv" | "json";
|
||||
|
||||
export interface ImportItem {
|
||||
date?: Date;
|
||||
description: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface ImportClientRef {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ImportInvoice {
|
||||
name: string;
|
||||
issueDate?: Date;
|
||||
dueDate?: Date;
|
||||
client?: ImportClientRef;
|
||||
clientId?: string;
|
||||
items: ImportItem[];
|
||||
sourceFile?: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const COLUMN_ALIASES: Record<string, string[]> = {
|
||||
date: ["date", "item date", "work date", "service date"],
|
||||
item: ["item", "title", "name", "task"],
|
||||
description: ["description", "desc", "details", "work", "notes"],
|
||||
quantity: ["quantity", "qty", "hours", "hour", "units", "amount hours"],
|
||||
rate: ["rate", "hourly rate", "price", "unit price", "unit_rate"],
|
||||
};
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.trim().toLowerCase().replace(/[_-]+/g, " ");
|
||||
}
|
||||
|
||||
function resolveColumnIndex(
|
||||
headers: string[],
|
||||
field: keyof typeof COLUMN_ALIASES,
|
||||
): number {
|
||||
const aliases = COLUMN_ALIASES[field] ?? [];
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const normalized = normalizeHeader(headers[i] ?? "");
|
||||
if (aliases.includes(normalized)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
current += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
i++;
|
||||
}
|
||||
} else if (char === "," && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = "";
|
||||
i++;
|
||||
} else {
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseFlexibleDate(dateStr: string): Date | undefined {
|
||||
const trimmed = dateStr.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
// ISO date (YYYY-MM-DD)
|
||||
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
||||
if (isoMatch) {
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
|
||||
// M/DD/YY or M/DD/YYYY
|
||||
const slashParts = trimmed.split("/");
|
||||
if (slashParts.length === 3) {
|
||||
const month = parseInt(slashParts[0] ?? "1", 10) - 1;
|
||||
const day = parseInt(slashParts[1] ?? "1", 10);
|
||||
let year = parseInt(slashParts[2] ?? "2000", 10);
|
||||
if (year < 100) year += 2000;
|
||||
const d = new Date(year, month, day);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseNumber(value: string): number {
|
||||
const cleaned = value.replace(/[$,\s]/g, "");
|
||||
const n = parseFloat(cleaned);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function buildItemDescription(item: string, description: string): string {
|
||||
const parts = [item.trim(), description.trim()].filter(Boolean);
|
||||
return parts.join(" — ") || "Imported item";
|
||||
}
|
||||
|
||||
function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
|
||||
const itemDates = items
|
||||
.map((i) => i.date)
|
||||
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
|
||||
if (itemDates.length > 0) {
|
||||
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
|
||||
}
|
||||
return fallback ?? new Date();
|
||||
}
|
||||
|
||||
function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
}
|
||||
|
||||
export function parseInvoiceCSV(
|
||||
csvText: string,
|
||||
filename: string,
|
||||
): ImportInvoice {
|
||||
const errors: string[] = [];
|
||||
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
name: stripExtension(filename),
|
||||
items: [],
|
||||
sourceFile: filename,
|
||||
errors: ["File is empty"],
|
||||
};
|
||||
}
|
||||
|
||||
const headers = parseCSVLine(lines[0] ?? "");
|
||||
const dateIdx = resolveColumnIndex(headers, "date");
|
||||
const itemIdx = resolveColumnIndex(headers, "item");
|
||||
const descIdx = resolveColumnIndex(headers, "description");
|
||||
const qtyIdx = resolveColumnIndex(headers, "quantity");
|
||||
const rateIdx = resolveColumnIndex(headers, "rate");
|
||||
|
||||
if (descIdx === -1 && itemIdx === -1) {
|
||||
errors.push(
|
||||
'Missing description column (expected "description" or "item")',
|
||||
);
|
||||
}
|
||||
if (qtyIdx === -1) {
|
||||
errors.push('Missing quantity column (expected "quantity" or "hours")');
|
||||
}
|
||||
if (rateIdx === -1) {
|
||||
errors.push('Missing rate column (expected "rate" or "price")');
|
||||
}
|
||||
|
||||
const items: ImportItem[] = [];
|
||||
|
||||
for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) {
|
||||
const values = parseCSVLine(lines[rowIdx] ?? "");
|
||||
if (values.every((v) => !v.trim())) continue;
|
||||
|
||||
const itemText = itemIdx >= 0 ? (values[itemIdx] ?? "") : "";
|
||||
const descText = descIdx >= 0 ? (values[descIdx] ?? "") : "";
|
||||
const description = buildItemDescription(itemText, descText);
|
||||
|
||||
const quantity = qtyIdx >= 0 ? parseNumber(values[qtyIdx] ?? "0") : 0;
|
||||
const rate = rateIdx >= 0 ? parseNumber(values[rateIdx] ?? "0") : 0;
|
||||
|
||||
if (!description || description === "Imported item") {
|
||||
if (!itemText && !descText) continue;
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
errors.push(`Row ${rowIdx + 1}: quantity must be greater than 0`);
|
||||
continue;
|
||||
}
|
||||
if (rate <= 0) {
|
||||
errors.push(`Row ${rowIdx + 1}: rate must be greater than 0`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let date: Date | undefined;
|
||||
if (dateIdx >= 0) {
|
||||
const rawDate = values[dateIdx] ?? "";
|
||||
if (rawDate.trim()) {
|
||||
date = parseFlexibleDate(rawDate);
|
||||
if (!date) {
|
||||
errors.push(`Row ${rowIdx + 1}: invalid date "${rawDate}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ date, description, quantity, rate });
|
||||
}
|
||||
|
||||
const issueDate = deriveIssueDate(items);
|
||||
|
||||
return {
|
||||
name: stripExtension(filename),
|
||||
issueDate,
|
||||
dueDate: defaultDueDate(issueDate),
|
||||
items,
|
||||
sourceFile: filename,
|
||||
errors:
|
||||
items.length === 0 && errors.length === 0
|
||||
? ["No valid line items found"]
|
||||
: errors,
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonInvoiceItem {
|
||||
date?: string;
|
||||
description?: string;
|
||||
item?: string;
|
||||
quantity?: number;
|
||||
hours?: number;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
interface JsonInvoice {
|
||||
name?: string;
|
||||
invoiceNumber?: string;
|
||||
issueDate?: string;
|
||||
dueDate?: string;
|
||||
client?: { name?: string; email?: string };
|
||||
clientName?: string;
|
||||
items?: JsonInvoiceItem[];
|
||||
}
|
||||
|
||||
function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
|
||||
const errors: string[] = [];
|
||||
const name = raw.name ?? raw.invoiceNumber ?? `Imported Invoice ${index + 1}`;
|
||||
|
||||
const clientName = raw.client?.name ?? raw.clientName;
|
||||
const clientEmail = raw.client?.email;
|
||||
|
||||
const items: ImportItem[] = (raw.items ?? []).map((item, itemIdx) => {
|
||||
const description = buildItemDescription(
|
||||
item.item ?? "",
|
||||
item.description ?? "",
|
||||
);
|
||||
const quantity = item.quantity ?? item.hours ?? 0;
|
||||
const rate = item.rate ?? 0;
|
||||
|
||||
if (!description || description === "Imported item") {
|
||||
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
|
||||
}
|
||||
if (quantity <= 0) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: quantity must be greater than 0`,
|
||||
);
|
||||
}
|
||||
if (rate <= 0) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: rate must be greater than 0`,
|
||||
);
|
||||
}
|
||||
|
||||
let date: Date | undefined;
|
||||
if (item.date) {
|
||||
date = parseFlexibleDate(item.date);
|
||||
if (!date) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: invalid date "${item.date}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { date, description, quantity, rate };
|
||||
});
|
||||
|
||||
let issueDate: Date | undefined;
|
||||
if (raw.issueDate) {
|
||||
issueDate = parseFlexibleDate(raw.issueDate);
|
||||
if (!issueDate) {
|
||||
errors.push(`Invoice "${name}": invalid issue date "${raw.issueDate}"`);
|
||||
}
|
||||
}
|
||||
|
||||
let dueDate: Date | undefined;
|
||||
if (raw.dueDate) {
|
||||
dueDate = parseFlexibleDate(raw.dueDate);
|
||||
if (!dueDate) {
|
||||
errors.push(`Invoice "${name}": invalid due date "${raw.dueDate}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIssue = issueDate ?? deriveIssueDate(items);
|
||||
const resolvedDue = dueDate ?? defaultDueDate(resolvedIssue);
|
||||
|
||||
if (items.length === 0) {
|
||||
errors.push(`Invoice "${name}": at least one item is required`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
issueDate: resolvedIssue,
|
||||
dueDate: resolvedDue,
|
||||
client:
|
||||
clientName || clientEmail
|
||||
? { name: clientName, email: clientEmail }
|
||||
: undefined,
|
||||
items,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
name: "JSON Import",
|
||||
items: [],
|
||||
errors: ["Invalid JSON format"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let rawInvoices: JsonInvoice[] = [];
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
rawInvoices = parsed as JsonInvoice[];
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (Array.isArray(obj.invoices)) {
|
||||
rawInvoices = obj.invoices as JsonInvoice[];
|
||||
} else if (obj.items || obj.name || obj.invoiceNumber) {
|
||||
rawInvoices = [obj];
|
||||
}
|
||||
}
|
||||
|
||||
if (rawInvoices.length === 0) {
|
||||
return [
|
||||
{
|
||||
name: "JSON Import",
|
||||
items: [],
|
||||
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return rawInvoices.map((inv, idx) => normalizeJsonInvoice(inv, idx));
|
||||
}
|
||||
|
||||
export function detectImportFormat(filename: string): ImportFormat {
|
||||
return filename.toLowerCase().endsWith(".json") ? "json" : "csv";
|
||||
}
|
||||
Reference in New Issue
Block a user