375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { AppShell } from "../../components/app-shell";
|
|
|
|
type ApiResponse<T> = {
|
|
data: T;
|
|
meta: { timestamp: string; version: "v1" };
|
|
error: null | { message: string; code?: string };
|
|
};
|
|
|
|
type TaxReturn = {
|
|
id: string;
|
|
taxYear: number;
|
|
filingType: "individual" | "business";
|
|
jurisdictions: string[];
|
|
status: "draft" | "ready" | "exported";
|
|
updatedAt: string;
|
|
};
|
|
|
|
export default function TaxPage() {
|
|
const [returns, setReturns] = useState<TaxReturn[]>([]);
|
|
const [status, setStatus] = useState("");
|
|
const [useLocal, setUseLocal] = useState(true);
|
|
const [useSample, setUseSample] = useState(true);
|
|
const [year, setYear] = useState(new Date().getFullYear());
|
|
const [filingType, setFilingType] = useState<"individual" | "business">("individual");
|
|
const [jurisdictions, setJurisdictions] = useState<string[]>(["CA", "NY"]);
|
|
|
|
const sampleProfile = {
|
|
taxpayer: {
|
|
name: "John Doe",
|
|
ssn: "111-22-3333",
|
|
dob: "1989-04-12",
|
|
filingStatus: "Single",
|
|
address: "123 Market St, San Francisco, CA"
|
|
},
|
|
income: {
|
|
w2: [{ employer: "Northwind Labs", wages: 92000, federalWithheld: 12000 }],
|
|
interest: [{ payer: "City Bank", amount: 320 }],
|
|
dividends: [{ payer: "Index Fund", amount: 480 }],
|
|
selfEmployment: [{ business: "Doe Consulting", income: 28000, expenses: 6400 }]
|
|
},
|
|
deductions: {
|
|
standard: true,
|
|
charitable: 600,
|
|
studentLoanInterest: 900
|
|
},
|
|
credits: {
|
|
education: 0,
|
|
childTax: 0
|
|
},
|
|
documents: [
|
|
"W-2 (Northwind Labs)",
|
|
"1099-INT (City Bank)",
|
|
"1099-DIV (Index Fund)",
|
|
"1099-NEC (Doe Consulting)",
|
|
"Health Insurance 1095-A",
|
|
"State withholding statement"
|
|
]
|
|
};
|
|
|
|
const states = [
|
|
{ code: "AL", name: "Alabama" },
|
|
{ code: "AK", name: "Alaska" },
|
|
{ code: "AZ", name: "Arizona" },
|
|
{ code: "AR", name: "Arkansas" },
|
|
{ code: "CA", name: "California" },
|
|
{ code: "CO", name: "Colorado" },
|
|
{ code: "CT", name: "Connecticut" },
|
|
{ code: "DE", name: "Delaware" },
|
|
{ code: "FL", name: "Florida" },
|
|
{ code: "GA", name: "Georgia" },
|
|
{ code: "HI", name: "Hawaii" },
|
|
{ code: "ID", name: "Idaho" },
|
|
{ code: "IL", name: "Illinois" },
|
|
{ code: "IN", name: "Indiana" },
|
|
{ code: "IA", name: "Iowa" },
|
|
{ code: "KS", name: "Kansas" },
|
|
{ code: "KY", name: "Kentucky" },
|
|
{ code: "LA", name: "Louisiana" },
|
|
{ code: "ME", name: "Maine" },
|
|
{ code: "MD", name: "Maryland" },
|
|
{ code: "MA", name: "Massachusetts" },
|
|
{ code: "MI", name: "Michigan" },
|
|
{ code: "MN", name: "Minnesota" },
|
|
{ code: "MS", name: "Mississippi" },
|
|
{ code: "MO", name: "Missouri" },
|
|
{ code: "MT", name: "Montana" },
|
|
{ code: "NE", name: "Nebraska" },
|
|
{ code: "NV", name: "Nevada" },
|
|
{ code: "NH", name: "New Hampshire" },
|
|
{ code: "NJ", name: "New Jersey" },
|
|
{ code: "NM", name: "New Mexico" },
|
|
{ code: "NY", name: "New York" },
|
|
{ code: "NC", name: "North Carolina" },
|
|
{ code: "ND", name: "North Dakota" },
|
|
{ code: "OH", name: "Ohio" },
|
|
{ code: "OK", name: "Oklahoma" },
|
|
{ code: "OR", name: "Oregon" },
|
|
{ code: "PA", name: "Pennsylvania" },
|
|
{ code: "RI", name: "Rhode Island" },
|
|
{ code: "SC", name: "South Carolina" },
|
|
{ code: "SD", name: "South Dakota" },
|
|
{ code: "TN", name: "Tennessee" },
|
|
{ code: "TX", name: "Texas" },
|
|
{ code: "UT", name: "Utah" },
|
|
{ code: "VT", name: "Vermont" },
|
|
{ code: "VA", name: "Virginia" },
|
|
{ code: "WA", name: "Washington" },
|
|
{ code: "WV", name: "West Virginia" },
|
|
{ code: "WI", name: "Wisconsin" },
|
|
{ code: "WY", name: "Wyoming" }
|
|
];
|
|
|
|
const localKey = "ledgerone_tax_returns";
|
|
|
|
const ensureUserId = () => {
|
|
if (typeof window === "undefined") {
|
|
return "";
|
|
}
|
|
let userId = localStorage.getItem("ledgerone_user_id");
|
|
if (!userId) {
|
|
userId = `demo_${crypto.randomUUID()}`;
|
|
localStorage.setItem("ledgerone_user_id", userId);
|
|
}
|
|
return userId;
|
|
};
|
|
|
|
const loadReturns = async () => {
|
|
if (useLocal && typeof window !== "undefined") {
|
|
const raw = localStorage.getItem(localKey);
|
|
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
|
|
setStatus("Running in local-only mode.");
|
|
return;
|
|
}
|
|
const userId = ensureUserId();
|
|
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
|
try {
|
|
const res = await fetch(`/api/tax/returns${query}`);
|
|
const payload = (await res.json()) as ApiResponse<TaxReturn[]>;
|
|
if (!res.ok || payload.error) {
|
|
throw new Error(payload.error?.message ?? "Unable to load returns.");
|
|
}
|
|
setReturns(payload.data);
|
|
setUseLocal(false);
|
|
return;
|
|
} catch {
|
|
if (typeof window !== "undefined") {
|
|
const raw = localStorage.getItem(localKey);
|
|
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
|
|
setUseLocal(true);
|
|
setStatus("Running in local-only mode (no backend).");
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadReturns().catch(() => {
|
|
setStatus("Unable to load returns.");
|
|
});
|
|
}, []);
|
|
|
|
const createReturn = async () => {
|
|
const userId = ensureUserId();
|
|
setStatus("Creating return...");
|
|
const payload = {
|
|
userId,
|
|
taxYear: year,
|
|
filingType,
|
|
jurisdictions
|
|
};
|
|
if (useLocal) {
|
|
const nextReturn: TaxReturn = {
|
|
id: `local_${crypto.randomUUID()}`,
|
|
taxYear: payload.taxYear,
|
|
filingType: payload.filingType,
|
|
jurisdictions: payload.jurisdictions,
|
|
status: "draft",
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
const next = [...returns, nextReturn];
|
|
localStorage.setItem(localKey, JSON.stringify(next));
|
|
setReturns(next);
|
|
setStatus("Return created locally.");
|
|
return;
|
|
}
|
|
const res = await fetch("/api/tax/returns", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const response = (await res.json()) as ApiResponse<TaxReturn>;
|
|
if (!res.ok || response.error) {
|
|
setStatus(response.error?.message ?? "Unable to create return.");
|
|
return;
|
|
}
|
|
setStatus("Return created.");
|
|
await loadReturns();
|
|
};
|
|
|
|
const exportReturn = async (id: string) => {
|
|
setStatus("Exporting return...");
|
|
if (useLocal) {
|
|
const ret = returns.find((item) => item.id === id);
|
|
const payload = {
|
|
return: ret,
|
|
documents: useSample ? sampleProfile.documents : [],
|
|
sampleData: useSample ? sampleProfile : null
|
|
};
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
|
type: "application/json"
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
setStatus("Export ready (local).");
|
|
return;
|
|
}
|
|
const res = await fetch(`/api/tax/returns/${id}/export`, { method: "POST" });
|
|
const response = (await res.json()) as ApiResponse<{
|
|
return: TaxReturn;
|
|
documents: unknown[];
|
|
}>;
|
|
if (!res.ok || response.error) {
|
|
setStatus(response.error?.message ?? "Export failed.");
|
|
return;
|
|
}
|
|
const blob = new Blob([JSON.stringify(response.data, null, 2)], {
|
|
type: "application/json"
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
setStatus("Export ready.");
|
|
await loadReturns();
|
|
};
|
|
|
|
return (
|
|
<AppShell title="Tax" subtitle="Prepare returns and export audit-ready packages.">
|
|
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
|
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
|
<h2 className="text-lg font-bold text-foreground">Create a return</h2>
|
|
<div className="mt-4 grid gap-4">
|
|
<div className="rounded-xl border border-border bg-background/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">Sample dataset</p>
|
|
<p className="text-xs text-muted-foreground">Use John Doe sample intake data.</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setUseSample((value) => !value)}
|
|
className={`rounded-full px-3 py-1 text-xs font-semibold transition-colors ${useSample ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground"
|
|
}`}
|
|
>
|
|
{useSample ? "On" : "Off"}
|
|
</button>
|
|
</div>
|
|
{useSample ? (
|
|
<div className="mt-4 text-xs text-muted-foreground">
|
|
<p>W-2 wages: $92,000 - 1099-NEC: $28,000</p>
|
|
<p>Standard deduction - CA + NY filings</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div>
|
|
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Tax year</label>
|
|
<input
|
|
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
type="number"
|
|
value={year}
|
|
onChange={(event) => setYear(Number(event.target.value))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
|
|
Filing type
|
|
</label>
|
|
<select
|
|
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
value={filingType}
|
|
onChange={(event) =>
|
|
setFilingType(event.target.value as "individual" | "business")
|
|
}
|
|
>
|
|
<option value="individual">Individual (1040)</option>
|
|
<option value="business">Business (1120/1065)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
|
|
Jurisdictions (states)
|
|
</label>
|
|
<select
|
|
className="mt-2 h-44 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
multiple
|
|
value={jurisdictions}
|
|
onChange={(event) =>
|
|
setJurisdictions(
|
|
Array.from(event.target.selectedOptions).map((option) => option.value)
|
|
)
|
|
}
|
|
>
|
|
{states.map((state) => (
|
|
<option key={state.code} value={state.code}>
|
|
{state.name} ({state.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Hold Ctrl/Command to select multiple states.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border bg-background/50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Required documents</p>
|
|
<div className="mt-3 grid gap-2 text-xs text-muted-foreground">
|
|
{sampleProfile.documents.map((doc) => (
|
|
<div key={doc} className="flex items-center justify-between">
|
|
<span>{doc}</span>
|
|
<span className="rounded-full bg-secondary px-2 py-1 text-foreground font-medium">Pending</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={createReturn}
|
|
className="rounded-xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
Create return
|
|
</button>
|
|
{status ? <p className="text-xs font-medium text-primary">{status}</p> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
|
<h2 className="text-lg font-bold text-foreground">Your returns</h2>
|
|
<div className="mt-4 space-y-4">
|
|
{returns.length ? (
|
|
returns.map((ret) => (
|
|
<div key={ret.id} className="rounded-xl border border-border bg-background/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-foreground">
|
|
{ret.taxYear} - {ret.filingType}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
States: {ret.jurisdictions.join(", ")}
|
|
</p>
|
|
{useSample ? (
|
|
<p className="text-xs text-muted-foreground">Sample: John Doe</p>
|
|
) : null}
|
|
</div>
|
|
<span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium text-foreground">
|
|
{ret.status}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => exportReturn(ret.id)}
|
|
className="mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
Export package
|
|
</button>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No returns yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|