compassmock/src/components/financials/invoice-dialog.tsx
Nicholai fbd31b58ae
feat(netsuite): add NetSuite integration and financials (#29)
* feat(schema): add auth, people, and financial tables

Add users, organizations, teams, groups, and project
members tables. Extend customers/vendors with netsuite
fields. Add netsuite schema for invoices, bills,
payments, and credit memos. Include all migrations,
seeds, new UI primitives, and config updates.

* feat(auth): add WorkOS authentication system

Add login, signup, password reset, email verification,
and invitation flows via WorkOS AuthKit. Includes auth
middleware, permission helpers, dev mode fallbacks,
and auth page components.

* feat(people): add people management system

Add user, team, group, and organization management
with CRUD actions, dashboard pages, invite dialog,
user drawer, and role-based filtering. Includes
WorkOS invitation integration.

* feat(netsuite): add NetSuite integration and financials

Add bidirectional NetSuite REST API integration with
OAuth 2.0, rate limiting, sync engine, and conflict
resolution. Includes invoices, vendor bills, payments,
credit memos CRUD, customer/vendor management pages,
and financial dashboard with tabbed views.

* ci: retrigger build

* fix: add mobile-list-card dependency for people-table

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-04 16:36:19 -07:00

280 lines
7.7 KiB
TypeScript
Executable File

"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import { DatePicker } from "@/components/ui/date-picker"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { LineItemsEditor, type LineItem } from "./line-items-editor"
import type { Invoice } from "@/db/schema-netsuite"
import type { Customer, Project } from "@/db/schema"
const INVOICE_STATUSES = [
"draft",
"sent",
"partially_paid",
"paid",
"overdue",
"void",
] as const
interface InvoiceDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Invoice | null
customers: Customer[]
projects: Project[]
onSubmit: (data: {
customerId: string
projectId: string | null
invoiceNumber: string
status: string
issueDate: string
dueDate: string
memo: string
lineItems: string
subtotal: number
tax: number
total: number
amountPaid: number
amountDue: number
}) => void
}
export function InvoiceDialog({
open,
onOpenChange,
initialData,
customers,
projects,
onSubmit,
}: InvoiceDialogProps) {
const [customerId, setCustomerId] = React.useState("")
const [projectId, setProjectId] = React.useState("")
const [invoiceNumber, setInvoiceNumber] = React.useState("")
const [status, setStatus] = React.useState("draft")
const [issueDate, setIssueDate] = React.useState("")
const [dueDate, setDueDate] = React.useState("")
const [memo, setMemo] = React.useState("")
const [tax, setTax] = React.useState(0)
const [lines, setLines] = React.useState<LineItem[]>([])
React.useEffect(() => {
if (initialData) {
setCustomerId(initialData.customerId)
setProjectId(initialData.projectId ?? "")
setInvoiceNumber(initialData.invoiceNumber ?? "")
setStatus(initialData.status)
setIssueDate(initialData.issueDate)
setDueDate(initialData.dueDate ?? "")
setMemo(initialData.memo ?? "")
setTax(initialData.tax)
setLines(
initialData.lineItems
? JSON.parse(initialData.lineItems)
: []
)
} else {
setCustomerId("")
setProjectId("")
setInvoiceNumber("")
setStatus("draft")
setIssueDate(new Date().toISOString().split("T")[0])
setDueDate("")
setMemo("")
setTax(0)
setLines([])
}
}, [initialData, open])
const subtotal = lines.reduce((s, l) => s + l.amount, 0)
const total = subtotal + tax
const amountDue = total - (initialData?.amountPaid ?? 0)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!customerId || !issueDate) return
onSubmit({
customerId,
projectId: projectId || null,
invoiceNumber,
status,
issueDate,
dueDate,
memo,
lineItems: JSON.stringify(lines),
subtotal,
tax,
total,
amountPaid: initialData?.amountPaid ?? 0,
amountDue,
})
}
const page1 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Customer *</Label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Project</Label>
<Select value={projectId || "none"} onValueChange={(v) => setProjectId(v === "none" ? "" : v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice #</Label>
<Input
className="h-9"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INVOICE_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)
const page2 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Issue Date *</Label>
<DatePicker
value={issueDate}
onChange={setIssueDate}
placeholder="Select issue date"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Due Date</Label>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Select due date"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Memo</Label>
<Textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
</>
)
const page3 = (
<div className="space-y-1.5">
<Label className="text-xs">Line Items</Label>
<LineItemsEditor value={lines} onChange={setLines} />
</div>
)
const page4 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Tax</Label>
<Input
type="number"
className="h-9"
min={0}
step="any"
value={tax || ""}
onChange={(e) => setTax(parseFloat(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5 rounded-md bg-muted/50 p-3">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-medium">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Tax:</span>
<span className="font-medium">${tax.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm font-semibold pt-1 border-t">
<span>Total:</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
</>
)
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={initialData ? "Edit Invoice" : "New Invoice"}
className="max-w-2xl"
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody pages={[page1, page2, page3, page4]} />
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{initialData ? "Save Changes" : "Create Invoice"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}