mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: rename bill_approvals.vendor_name→approver_name + COA import dialog
Rename the bill_approvals approver column from vendor_name to approver_name across UI, hooks, types, and buildium-sync. Add a Chart of Accounts import dialog (XLSX upload + column mapping + type-alias normalization) and a "denied" status color. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId,
|
||||
return {
|
||||
association_id: clientId,
|
||||
bill_id: billId,
|
||||
vendor_name: bm?.member_name || 'Approver',
|
||||
approver_name: bm?.member_name || 'Approver',
|
||||
status: 'pending',
|
||||
notes: comment || null,
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ export function useBillApprovals() {
|
||||
approved_by: userData?.user?.id,
|
||||
status: 'approved',
|
||||
notes: comment,
|
||||
vendor_name: voterName || 'Unknown',
|
||||
approver_name: voterName || 'Unknown',
|
||||
association_id: (bills.find(b => b.id === billId))?.association_id
|
||||
})
|
||||
.select()
|
||||
@@ -76,7 +76,7 @@ export function useBillApprovals() {
|
||||
approved_by: userData?.user?.id,
|
||||
status: 'denied',
|
||||
notes: comment,
|
||||
vendor_name: voterName || 'Unknown',
|
||||
approver_name: voterName || 'Unknown',
|
||||
association_id: (bills.find(b => b.id === billId))?.association_id
|
||||
});
|
||||
if (approvalError) throw approvalError;
|
||||
|
||||
@@ -1781,7 +1781,7 @@ export type Database = {
|
||||
notes: string | null
|
||||
status: string
|
||||
updated_at: string
|
||||
vendor_name: string
|
||||
approver_name: string
|
||||
}
|
||||
Insert: {
|
||||
amount?: number
|
||||
@@ -1796,7 +1796,7 @@ export type Database = {
|
||||
notes?: string | null
|
||||
status?: string
|
||||
updated_at?: string
|
||||
vendor_name: string
|
||||
approver_name: string
|
||||
}
|
||||
Update: {
|
||||
amount?: number
|
||||
@@ -1811,7 +1811,7 @@ export type Database = {
|
||||
notes?: string | null
|
||||
status?: string
|
||||
updated_at?: string
|
||||
vendor_name?: string
|
||||
approver_name?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
|
||||
@@ -370,7 +370,7 @@ export default function AIInvoiceParserPage() {
|
||||
return {
|
||||
association_id: reviewForm.association_id,
|
||||
bill_id: newBill?.id || null,
|
||||
vendor_name: bm?.member_name || reviewData.vendor_name,
|
||||
approver_name: bm?.member_name || reviewData.vendor_name,
|
||||
amount: reviewData.total_amount || 0,
|
||||
invoice_id: invoice.id,
|
||||
status: "pending",
|
||||
|
||||
@@ -23,6 +23,7 @@ const statusColors: Record<string, string> = {
|
||||
pending: "bg-amber-100 text-amber-700",
|
||||
approved: "bg-emerald-100 text-emerald-700",
|
||||
rejected: "bg-red-100 text-red-700",
|
||||
denied: "bg-red-100 text-red-700",
|
||||
paid: "bg-emerald-100 text-emerald-700",
|
||||
overdue: "bg-red-100 text-red-700",
|
||||
draft: "bg-muted text-muted-foreground",
|
||||
@@ -164,7 +165,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
.from("bill_approvals")
|
||||
.select("bill_id")
|
||||
.in("association_id", boardAssociationIds!)
|
||||
.in("vendor_name", names)
|
||||
.in("approver_name", names)
|
||||
.not("bill_id", "is", null);
|
||||
assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean)));
|
||||
}
|
||||
@@ -218,7 +219,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
const billIds = billsList.map((b: any) => b.id);
|
||||
const { data: approvals } = await supabase
|
||||
.from("bill_approvals")
|
||||
.select("id, bill_id, vendor_name, status")
|
||||
.select("id, bill_id, approver_name, status")
|
||||
.in("bill_id", billIds);
|
||||
const grouped: Record<string, any[]> = {};
|
||||
(approvals || []).forEach((a: any) => {
|
||||
@@ -456,7 +457,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
return {
|
||||
association_id: form.association_id,
|
||||
bill_id: newBill?.id || null,
|
||||
vendor_name: bm?.member_name || vendorDisplayName,
|
||||
approver_name: bm?.member_name || vendorDisplayName,
|
||||
amount: parseFloat(form.amount) || 0,
|
||||
status: "pending",
|
||||
notes: form.description || null,
|
||||
@@ -1081,7 +1082,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
||||
<Badge variant="outline" className={`text-xs ${a.status === 'approved' ? 'border-emerald-300 text-emerald-700' : a.status === 'denied' || a.status === 'rejected' ? 'border-red-300 text-red-700' : 'border-amber-300 text-amber-700'}`}>
|
||||
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'}
|
||||
</Badge>
|
||||
<span className="text-xs truncate max-w-[120px]">{a.vendor_name}</span>
|
||||
<span className="text-xs truncate max-w-[120px]">{a.approver_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -169,8 +169,8 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
||||
|
||||
// Notify staff (admins/managers) that a board member has voted
|
||||
try {
|
||||
const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId);
|
||||
const voterName = approvalRecord?.vendor_name || "A board member";
|
||||
const approvalRecord = approvals.find((a: any) => a.id === approvalId);
|
||||
const voterName = approvalRecord?.approver_name || "A board member";
|
||||
const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`;
|
||||
const associationName = bill?.associations?.name || "an association";
|
||||
const verb = action === "approved" ? "approved" : "denied";
|
||||
@@ -462,7 +462,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
||||
{approvals.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between border rounded-lg p-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{a.vendor_name}</p>
|
||||
<p className="text-sm font-medium">{a.approver_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
@@ -472,7 +472,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
||||
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
|
||||
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
|
||||
</Badge>
|
||||
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && (
|
||||
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.approver_name) : true) && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "approved")} className="h-7 px-2 text-xs gap-1">
|
||||
<CheckCircle className="h-3 w-3" /> Approve
|
||||
@@ -702,7 +702,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{a.vendor_name || "—"}</p>
|
||||
<p className="text-sm font-medium">{a.approver_name || "—"}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -724,7 +724,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.vendor_name) : true) && (
|
||||
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2 } from "lucide-react";
|
||||
import { Plus, Trash2, CalendarIcon, Lock, Pencil, RotateCcw, Save, Info, Loader2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { money } from "./lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ChartOfAccountsImportDialog from "./components/ChartOfAccountsImportDialog";
|
||||
|
||||
const TYPES = [
|
||||
{ value: "asset", label: "Assets", tone: "bg-emerald-100 text-emerald-700", side: "debit" as const },
|
||||
@@ -40,6 +41,7 @@ export default function AccountingChartOfAccountsPage() {
|
||||
|
||||
// ── Account form (shared create/edit) ──
|
||||
const [open, setOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -252,6 +254,10 @@ export default function AccountingChartOfAccountsPage() {
|
||||
<h1 className="text-2xl font-semibold">Chart of Accounts</h1>
|
||||
<p className="text-sm text-muted-foreground">{(accounts as any[]).length} accounts across {TYPES.length} categories</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" /> Import
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) resetForm(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={resetForm}><Plus className="mr-1 h-4 w-4" /> New Account</Button>
|
||||
@@ -306,6 +312,15 @@ export default function AccountingChartOfAccountsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartOfAccountsImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
companyId={cid}
|
||||
existingAccounts={accounts as any[]}
|
||||
onSuccess={() => qc.invalidateQueries({ queryKey: ["accounts", cid] })}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="accounts">
|
||||
<TabsList>
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import { toast } from "sonner";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle2, AlertTriangle, ArrowRight, Loader2 } from "lucide-react";
|
||||
|
||||
const ACCOUNT_TYPES = ["asset", "liability", "equity", "income", "expense"] as const;
|
||||
type AccountType = (typeof ACCOUNT_TYPES)[number];
|
||||
|
||||
// Importable target fields, in the order shown in the mapping step.
|
||||
const FIELDS: { key: string; label: string; required: boolean; hint: string }[] = [
|
||||
{ key: "code", label: "Account code", required: false, hint: "e.g. 1100" },
|
||||
{ key: "name", label: "Account name", required: true, hint: "e.g. Operating Cash" },
|
||||
{ key: "type", label: "Type", required: true, hint: "Asset, Liability, Equity, Income, or Expense" },
|
||||
{ key: "description", label: "Description", required: false, hint: "Optional notes" },
|
||||
{ key: "is_bank", label: "Bank account?", required: false, hint: "Yes / No" },
|
||||
{ key: "is_reserve", label: "Reserve fund?", required: false, hint: "Yes / No" },
|
||||
];
|
||||
|
||||
// Map the many ways accounting systems label account types onto our 5 enum values.
|
||||
const TYPE_ALIASES: Record<string, AccountType> = {
|
||||
asset: "asset", assets: "asset", bank: "asset", cash: "asset",
|
||||
"accounts receivable": "asset", "a/r": "asset", ar: "asset", receivable: "asset",
|
||||
"fixed asset": "asset", "fixed assets": "asset", "other asset": "asset",
|
||||
"other current asset": "asset", "current asset": "asset",
|
||||
liability: "liability", liabilities: "liability",
|
||||
"accounts payable": "liability", "a/p": "liability", ap: "liability", payable: "liability",
|
||||
"credit card": "liability", "current liability": "liability",
|
||||
"other current liability": "liability", "long term liability": "liability",
|
||||
"long-term liability": "liability",
|
||||
equity: "equity", equities: "equity", "retained earnings": "equity", capital: "equity",
|
||||
income: "income", revenue: "income", revenues: "income", sales: "income",
|
||||
"other income": "income",
|
||||
expense: "expense", expenses: "expense", "cost of goods sold": "expense",
|
||||
cogs: "expense", "other expense": "expense",
|
||||
};
|
||||
|
||||
function normalizeType(raw: unknown): AccountType | null {
|
||||
const v = String(raw ?? "").trim().toLowerCase();
|
||||
if (!v) return null;
|
||||
if ((ACCOUNT_TYPES as readonly string[]).includes(v)) return v as AccountType;
|
||||
return TYPE_ALIASES[v] ?? null;
|
||||
}
|
||||
|
||||
function parseBool(raw: unknown): boolean {
|
||||
const v = String(raw ?? "").trim().toLowerCase();
|
||||
return ["yes", "y", "true", "1", "x", "✓"].includes(v);
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]/g, "");
|
||||
|
||||
type PreviewRow = {
|
||||
_id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
type: AccountType | null;
|
||||
rawType: string;
|
||||
description: string;
|
||||
is_bank: boolean;
|
||||
is_reserve: boolean;
|
||||
errors: string[];
|
||||
duplicate: boolean;
|
||||
};
|
||||
|
||||
export default function ChartOfAccountsImportDialog({
|
||||
open, onOpenChange, companyId, existingAccounts, onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
companyId: string;
|
||||
existingAccounts: { code?: string | null }[];
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [headers, setHeaders] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<Record<string, any>[]>([]);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [preview, setPreview] = useState<PreviewRow[]>([]);
|
||||
const [summary, setSummary] = useState<{ total: number; imported: number; skipped: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStep(1); setBusy(false); setFile(null); setHeaders([]); setRows([]);
|
||||
setMapping({}); setPreview([]); setSummary(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const existingCodes = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const a of existingAccounts) {
|
||||
const c = String(a.code ?? "").trim().toLowerCase();
|
||||
if (c) s.add(c);
|
||||
}
|
||||
return s;
|
||||
}, [existingAccounts]);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const ok = /\.(csv|xlsx|xls)$/i.test(f.name) || f.type === "text/csv";
|
||||
if (!ok) {
|
||||
toast.error("Please upload a CSV or Excel file.");
|
||||
return;
|
||||
}
|
||||
setFile(f);
|
||||
};
|
||||
|
||||
const parseFile = () => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target!.result as ArrayBuffer);
|
||||
const wb = XLSX.read(data, { type: "array" });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const json = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, blankrows: false });
|
||||
if (json.length < 2) {
|
||||
toast.error("The file appears to be empty or has only a header row.");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const hdrs = (json[0] as any[]).map((h) => String(h ?? "").trim()).filter(Boolean);
|
||||
const dataRows = json.slice(1).map((r) => {
|
||||
const obj: Record<string, any> = {};
|
||||
hdrs.forEach((h, i) => { obj[h] = (r as any[])[i]; });
|
||||
return obj;
|
||||
});
|
||||
setHeaders(hdrs);
|
||||
setRows(dataRows);
|
||||
|
||||
// Auto-map by header name.
|
||||
const auto: Record<string, string> = {};
|
||||
for (const field of FIELDS) {
|
||||
const match = hdrs.find((h) => norm(h) === norm(field.key) || norm(h) === norm(field.label));
|
||||
if (match) auto[field.key] = match;
|
||||
}
|
||||
setMapping(auto);
|
||||
setStep(2);
|
||||
} catch {
|
||||
toast.error("Could not read the file.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const buildPreview = () => {
|
||||
const missingRequired = FIELDS.filter((f) => f.required && !mapping[f.key]);
|
||||
if (missingRequired.length) {
|
||||
toast.error(`Map required fields: ${missingRequired.map((f) => f.label).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const seenCodes = new Set<string>();
|
||||
const out: PreviewRow[] = rows.map((row, i) => {
|
||||
const get = (key: string) => (mapping[key] ? row[mapping[key]] : undefined);
|
||||
const code = String(get("code") ?? "").trim();
|
||||
const name = String(get("name") ?? "").trim();
|
||||
const rawType = String(get("type") ?? "").trim();
|
||||
const type = normalizeType(rawType);
|
||||
const description = String(get("description") ?? "").trim();
|
||||
const is_bank = mapping.is_bank ? parseBool(get("is_bank")) : false;
|
||||
const is_reserve = mapping.is_reserve ? parseBool(get("is_reserve")) : false;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (!name) errors.push("Name is required");
|
||||
if (!type) errors.push(rawType ? `Unrecognized type "${rawType}"` : "Type is required");
|
||||
|
||||
const codeKey = code.toLowerCase();
|
||||
let duplicate = false;
|
||||
if (codeKey) {
|
||||
if (existingCodes.has(codeKey)) duplicate = true;
|
||||
else if (seenCodes.has(codeKey)) duplicate = true;
|
||||
seenCodes.add(codeKey);
|
||||
}
|
||||
|
||||
return { _id: i, code, name, type, rawType, description, is_bank, is_reserve, errors, duplicate };
|
||||
});
|
||||
setPreview(out);
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const importable = useMemo(
|
||||
() => preview.filter((r) => r.errors.length === 0 && !r.duplicate),
|
||||
[preview]
|
||||
);
|
||||
|
||||
const runImport = async () => {
|
||||
if (importable.length === 0) {
|
||||
toast.error("No valid new accounts to import.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = importable.map((r) => ({
|
||||
company_id: companyId,
|
||||
name: r.name,
|
||||
code: r.code || null,
|
||||
type: r.type,
|
||||
description: r.description || null,
|
||||
is_bank: r.is_bank,
|
||||
is_reserve: r.is_reserve,
|
||||
}));
|
||||
|
||||
const CHUNK = 200;
|
||||
for (let i = 0; i < payload.length; i += CHUNK) {
|
||||
const { error } = await accounting.from("accounts").insert(payload.slice(i, i + CHUNK));
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
setSummary({
|
||||
total: preview.length,
|
||||
imported: importable.length,
|
||||
skipped: preview.length - importable.length,
|
||||
});
|
||||
setStep(4);
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "Import failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorCount = preview.filter((r) => r.errors.length > 0).length;
|
||||
const dupCount = preview.filter((r) => r.errors.length === 0 && r.duplicate).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!busy) onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{step === 1 && "Import Chart of Accounts"}
|
||||
{step === 2 && "Map Columns"}
|
||||
{step === 3 && "Preview Accounts"}
|
||||
{step === 4 && "Import Complete"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 1 && "Upload a CSV or Excel file containing your account list."}
|
||||
{step === 2 && "Match your file's columns to account fields."}
|
||||
{step === 3 && "Review accounts before importing. Existing codes are skipped."}
|
||||
{step === 4 && "Review the results of the import."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 min-h-[280px]">
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Expected columns</AlertTitle>
|
||||
<AlertDescription className="mt-1 text-sm leading-relaxed">
|
||||
At minimum include an <strong>Account name</strong> and a <strong>Type</strong>{" "}
|
||||
(Asset, Liability, Equity, Income, or Expense). Optional columns: Account code,
|
||||
Description, Bank account?, Reserve fund?. You'll map them on the next step.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coa-import-file" className="text-sm font-semibold">Select file</Label>
|
||||
<Input
|
||||
id="coa-import-file"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
||||
onChange={handleFile}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{file && <p className="text-xs text-muted-foreground">Selected: {file.name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.key} className="space-y-1.5 p-3 border rounded-lg bg-muted/40">
|
||||
<Label className="font-medium text-sm flex items-center">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{field.hint}</p>
|
||||
<Select
|
||||
value={mapping[field.key] ?? "__none__"}
|
||||
onValueChange={(v) =>
|
||||
setMapping((m) => {
|
||||
const next = { ...m };
|
||||
if (v === "__none__") delete next[field.key];
|
||||
else next[field.key] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={`bg-background ${field.required && !mapping[field.key] ? "border-amber-300" : ""}`}>
|
||||
<SelectValue placeholder="Select a column…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Not mapped —</SelectItem>
|
||||
{headers.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<Alert variant={errorCount > 0 ? "destructive" : "default"}>
|
||||
{errorCount > 0
|
||||
? <AlertCircle className="h-4 w-4" />
|
||||
: <CheckCircle2 className="h-4 w-4 text-emerald-600" />}
|
||||
<AlertTitle>{errorCount > 0 ? "Some rows have errors" : "Ready to import"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{importable.length} new account(s) will be imported
|
||||
{dupCount > 0 ? `, ${dupCount} duplicate(s) skipped` : ""}
|
||||
{errorCount > 0 ? `, ${errorCount} row(s) with errors skipped` : ""}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table className="min-w-[640px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">Code</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Bank</TableHead>
|
||||
<TableHead className="w-20">Reserve</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{preview.map((r) => {
|
||||
const bad = r.errors.length > 0;
|
||||
return (
|
||||
<TableRow key={r._id} className={bad ? "bg-destructive/5" : (r.duplicate ? "bg-amber-50/40" : "")}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{r.code || "—"}</TableCell>
|
||||
<TableCell className="text-sm">{r.name || <span className="text-destructive">Missing</span>}</TableCell>
|
||||
<TableCell className="text-sm capitalize">
|
||||
{r.type ?? <span className="text-destructive">{r.rawType || "Missing"}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{r.is_bank ? "Yes" : "—"}</TableCell>
|
||||
<TableCell className="text-xs">{r.is_reserve ? "Yes" : "—"}</TableCell>
|
||||
<TableCell>
|
||||
{bad ? (
|
||||
<span title={r.errors.join(", ")}><AlertCircle className="h-4 w-4 text-destructive" /></span>
|
||||
) : r.duplicate ? (
|
||||
<span title="Code already exists — will be skipped"><AlertTriangle className="h-4 w-4 text-amber-500" /></span>
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && summary && (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-6">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-bold">Import Complete</h3>
|
||||
<p className="text-muted-foreground text-sm">Your chart of accounts has been updated.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
|
||||
<div className="bg-muted border rounded-xl p-4 text-center">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase mb-1">Total</p>
|
||||
<p className="text-2xl font-bold">{summary.total}</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-100 rounded-xl p-4 text-center">
|
||||
<p className="text-xs font-semibold text-emerald-600 uppercase mb-1">Imported</p>
|
||||
<p className="text-2xl font-bold text-emerald-700">{summary.imported}</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4 text-center">
|
||||
<p className="text-xs font-semibold text-amber-600 uppercase mb-1">Skipped</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{summary.skipped}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
{step < 4 ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>Cancel</Button>
|
||||
{step === 1 && (
|
||||
<Button onClick={parseFile} disabled={!file || busy}>
|
||||
{busy ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Parsing…</> : "Continue"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep(1)} disabled={busy}>Back</Button>
|
||||
<Button onClick={buildPreview} disabled={busy}>Generate Preview</Button>
|
||||
</>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep(2)} disabled={busy}>Back</Button>
|
||||
<Button onClick={runImport} disabled={busy || importable.length === 0}>
|
||||
{busy ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Importing…</> : `Import ${importable.length} account(s)`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => onOpenChange(false)}>Done <ArrowRight className="w-4 h-4 ml-2" /></Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v2.104.0
|
||||
v2.105.0
|
||||
@@ -2225,7 +2225,7 @@ Deno.serve(async (req) => {
|
||||
const { error: aErr } = await supabase.from("bill_approvals").insert({
|
||||
bill_id: finalBillId,
|
||||
association_id: assocLocalId,
|
||||
vendor_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor",
|
||||
approver_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor",
|
||||
amount,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user