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>
This commit is contained in:
Nicholai 2026-02-04 16:36:19 -07:00 committed by GitHub
parent 6a1afd7b49
commit fbd31b58ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 8083 additions and 0 deletions

120
src/app/actions/credit-memos.ts Executable file
View File

@ -0,0 +1,120 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { creditMemos, type NewCreditMemo } from "@/db/schema-netsuite"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getCreditMemos() {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
return db.select().from(creditMemos)
}
export async function getCreditMemo(id: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(creditMemos)
.where(eq(creditMemos.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createCreditMemo(
data: Omit<NewCreditMemo, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(creditMemos).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/financials")
return { success: true, id }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to create credit memo",
}
}
}
export async function updateCreditMemo(
id: string,
data: Partial<NewCreditMemo>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(creditMemos)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(creditMemos.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to update credit memo",
}
}
}
export async function deleteCreditMemo(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(creditMemos).where(eq(creditMemos.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to delete credit memo",
}
}
}

114
src/app/actions/customers.ts Executable file
View File

@ -0,0 +1,114 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { customers, type NewCustomer } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getCustomers() {
const user = await getCurrentUser()
requirePermission(user, "customer", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
return db.select().from(customers)
}
export async function getCustomer(id: string) {
const user = await getCurrentUser()
requirePermission(user, "customer", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(customers)
.where(eq(customers.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createCustomer(
data: Omit<NewCustomer, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "customer", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(customers).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/customers")
return { success: true, id }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to create customer",
}
}
}
export async function updateCustomer(
id: string,
data: Partial<NewCustomer>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "customer", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(customers)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(customers.id, id))
revalidatePath("/dashboard/customers")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to update customer",
}
}
}
export async function deleteCustomer(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "customer", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(customers).where(eq(customers.id, id))
revalidatePath("/dashboard/customers")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to delete customer",
}
}
}

117
src/app/actions/invoices.ts Executable file
View File

@ -0,0 +1,117 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { invoices, type NewInvoice } from "@/db/schema-netsuite"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getInvoices(projectId?: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
if (projectId) {
return db
.select()
.from(invoices)
.where(eq(invoices.projectId, projectId))
}
return db.select().from(invoices)
}
export async function getInvoice(id: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(invoices)
.where(eq(invoices.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createInvoice(
data: Omit<NewInvoice, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(invoices).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/financials")
return { success: true, id }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to create invoice",
}
}
}
export async function updateInvoice(
id: string,
data: Partial<NewInvoice>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(invoices)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(invoices.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update invoice",
}
}
}
export async function deleteInvoice(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(invoices).where(eq(invoices.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete invoice",
}
}
}

238
src/app/actions/netsuite-sync.ts Executable file
View File

@ -0,0 +1,238 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { customers, vendors } from "@/db/schema"
import { getNetSuiteConfig } from "@/lib/netsuite/config"
import { TokenManager } from "@/lib/netsuite/auth/token-manager"
import { getAuthorizeUrl } from "@/lib/netsuite/auth/oauth-client"
import { SyncEngine } from "@/lib/netsuite/sync/sync-engine"
import { CustomerMapper } from "@/lib/netsuite/mappers/customer-mapper"
import { VendorMapper } from "@/lib/netsuite/mappers/vendor-mapper"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getNetSuiteConnectionStatus() {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const config = getNetSuiteConfig(envRecord)
const db = getDb(env.DB)
const tokenManager = new TokenManager(config, db as never)
const connected = await tokenManager.hasTokens()
return {
configured: true,
connected,
accountId: config.accountId,
}
} catch {
return { configured: false, connected: false, accountId: null }
}
}
export async function initiateNetSuiteOAuth() {
try {
const user = await getCurrentUser()
requirePermission(user, "organization", "update")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const config = getNetSuiteConfig(envRecord)
const state = crypto.randomUUID()
const authorizeUrl = getAuthorizeUrl(config, state)
return { success: true, authorizeUrl, state }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Configuration error",
}
}
}
export async function disconnectNetSuite() {
try {
const user = await getCurrentUser()
requirePermission(user, "organization", "update")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const config = getNetSuiteConfig(envRecord)
const db = getDb(env.DB)
const tokenManager = new TokenManager(config, db as never)
await tokenManager.clearTokens()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to disconnect",
}
}
}
export async function syncCustomers() {
try {
const user = await getCurrentUser()
requirePermission(user, "customer", "update")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const db = getDb(env.DB)
const engine = new SyncEngine(db as never, envRecord)
const mapper = new CustomerMapper()
const result = await engine.pull(
mapper,
async (localId, data) => {
const now = new Date().toISOString()
if (localId) {
await db
.update(customers)
.set({ ...data, updatedAt: now } as never)
.where(eq(customers.id, localId))
return localId
}
const id = crypto.randomUUID()
await db.insert(customers).values({
id,
name: String(data.name ?? ""),
email: (data.email as string) ?? null,
phone: (data.phone as string) ?? null,
netsuiteId: (data.netsuiteId as string) ?? null,
createdAt: now,
updatedAt: now,
})
return id
}
)
revalidatePath("/dashboard")
return {
success: true,
pulled: result.pull?.pulled ?? 0,
created: result.pull?.created ?? 0,
updated: result.pull?.updated ?? 0,
}
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Sync failed",
}
}
}
export async function syncVendors() {
try {
const user = await getCurrentUser()
requirePermission(user, "vendor", "update")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const db = getDb(env.DB)
const engine = new SyncEngine(db as never, envRecord)
const mapper = new VendorMapper()
const result = await engine.pull(
mapper,
async (localId, data) => {
const now = new Date().toISOString()
if (localId) {
await db
.update(vendors)
.set({ ...data, updatedAt: now } as never)
.where(eq(vendors.id, localId))
return localId
}
const id = crypto.randomUUID()
await db.insert(vendors).values({
id,
name: String(data.name ?? ""),
category: (data.category as string) ?? "Subcontractor",
email: (data.email as string) ?? null,
phone: (data.phone as string) ?? null,
address: (data.address as string) ?? null,
netsuiteId: (data.netsuiteId as string) ?? null,
createdAt: now,
updatedAt: now,
})
return id
}
)
revalidatePath("/dashboard")
return {
success: true,
pulled: result.pull?.pulled ?? 0,
created: result.pull?.created ?? 0,
updated: result.pull?.updated ?? 0,
}
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Sync failed",
}
}
}
export async function getSyncHistory() {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const db = getDb(env.DB)
const engine = new SyncEngine(db as never, envRecord)
return { success: true, history: await engine.getSyncHistory() }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
history: [],
}
}
}
export async function getConflicts() {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const db = getDb(env.DB)
const engine = new SyncEngine(db as never, envRecord)
return { success: true, conflicts: await engine.getConflicts() }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
conflicts: [],
}
}
}
export async function resolveConflict(
metaId: string,
resolution: "use_local" | "use_remote"
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "update")
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const db = getDb(env.DB)
const engine = new SyncEngine(db as never, envRecord)
await engine.resolveConflict(metaId, resolution)
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
}
}
}

114
src/app/actions/payments.ts Executable file
View File

@ -0,0 +1,114 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { payments, type NewPayment } from "@/db/schema-netsuite"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getPayments() {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
return db.select().from(payments)
}
export async function getPayment(id: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(payments)
.where(eq(payments.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createPayment(
data: Omit<NewPayment, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(payments).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/financials")
return { success: true, id }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to create payment",
}
}
}
export async function updatePayment(
id: string,
data: Partial<NewPayment>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(payments)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(payments.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to update payment",
}
}
}
export async function deletePayment(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(payments).where(eq(payments.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to delete payment",
}
}
}

117
src/app/actions/vendor-bills.ts Executable file
View File

@ -0,0 +1,117 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { vendorBills, type NewVendorBill } from "@/db/schema-netsuite"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getVendorBills(projectId?: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
if (projectId) {
return db
.select()
.from(vendorBills)
.where(eq(vendorBills.projectId, projectId))
}
return db.select().from(vendorBills)
}
export async function getVendorBill(id: string) {
const user = await getCurrentUser()
requirePermission(user, "finance", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(vendorBills)
.where(eq(vendorBills.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createVendorBill(
data: Omit<NewVendorBill, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(vendorBills).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/financials")
return { success: true, id }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to create bill",
}
}
}
export async function updateVendorBill(
id: string,
data: Partial<NewVendorBill>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(vendorBills)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(vendorBills.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update bill",
}
}
}
export async function deleteVendorBill(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "finance", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(vendorBills).where(eq(vendorBills.id, id))
revalidatePath("/dashboard/financials")
return { success: true }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete bill",
}
}
}

114
src/app/actions/vendors.ts Executable file
View File

@ -0,0 +1,114 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import { vendors, type NewVendor } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
export async function getVendors() {
const user = await getCurrentUser()
requirePermission(user, "vendor", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
return db.select().from(vendors)
}
export async function getVendor(id: string) {
const user = await getCurrentUser()
requirePermission(user, "vendor", "read")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(vendors)
.where(eq(vendors.id, id))
.limit(1)
return rows[0] ?? null
}
export async function createVendor(
data: Omit<NewVendor, "id" | "createdAt" | "updatedAt">
) {
try {
const user = await getCurrentUser()
requirePermission(user, "vendor", "create")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const id = crypto.randomUUID()
await db.insert(vendors).values({
id,
...data,
createdAt: now,
updatedAt: now,
})
revalidatePath("/dashboard/vendors")
return { success: true, id }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to create vendor",
}
}
}
export async function updateVendor(
id: string,
data: Partial<NewVendor>
) {
try {
const user = await getCurrentUser()
requirePermission(user, "vendor", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.update(vendors)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(vendors.id, id))
revalidatePath("/dashboard/vendors")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to update vendor",
}
}
}
export async function deleteVendor(id: string) {
try {
const user = await getCurrentUser()
requirePermission(user, "vendor", "delete")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db.delete(vendors).where(eq(vendors.id, id))
revalidatePath("/dashboard/vendors")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error ? err.message : "Failed to delete vendor",
}
}
}

View File

@ -0,0 +1,82 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { getNetSuiteConfig } from "@/lib/netsuite/config"
import { exchangeCodeForTokens } from "@/lib/netsuite/auth/oauth-client"
import { TokenManager } from "@/lib/netsuite/auth/token-manager"
import { getCurrentUser } from "@/lib/auth"
import { can } from "@/lib/permissions"
export async function GET(request: Request) {
const user = await getCurrentUser()
if (!user || !can(user, "organization", "update")) {
return Response.redirect(
new URL("/dashboard?error=unauthorized", request.url)
)
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
if (error) {
const desc = url.searchParams.get("error_description") ?? error
return redirectToSettings(`error=${encodeURIComponent(desc)}`)
}
if (!code || !state) {
return redirectToSettings("error=Missing+code+or+state")
}
// validate state matches what we stored in the cookie
const cookies = parseCookies(request.headers.get("cookie") ?? "")
const expectedState = cookies["netsuite_oauth_state"]
if (!expectedState || state !== expectedState) {
return redirectToSettings("error=Invalid+state+parameter")
}
try {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const config = getNetSuiteConfig(envRecord)
const db = getDb(env.DB)
const tokens = await exchangeCodeForTokens(config, code)
const tokenManager = new TokenManager(config, db as never)
await tokenManager.storeTokens(tokens)
return redirectToSettings("connected=true", true)
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"
return redirectToSettings(
`error=${encodeURIComponent(message)}`
)
}
}
function redirectToSettings(
params: string,
clearCookie = false
): Response {
const headers = new Headers({
Location: `/dashboard?netsuite=${params}`,
})
if (clearCookie) {
headers.set(
"Set-Cookie",
"netsuite_oauth_state=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
)
}
return new Response(null, { status: 302, headers })
}
function parseCookies(header: string): Record<string, string> {
const cookies: Record<string, string> = {}
for (const pair of header.split(";")) {
const [key, ...rest] = pair.trim().split("=")
if (key) cookies[key] = rest.join("=")
}
return cookies
}

View File

@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { toast } from "sonner"
import {
getCustomers,
createCustomer,
updateCustomer,
deleteCustomer,
} from "@/app/actions/customers"
import type { Customer } from "@/db/schema"
import { Button } from "@/components/ui/button"
import { CustomersTable } from "@/components/financials/customers-table"
import { CustomerDialog } from "@/components/financials/customer-dialog"
export default function CustomersPage() {
const [customers, setCustomers] = React.useState<Customer[]>([])
const [loading, setLoading] = React.useState(true)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editing, setEditing] = React.useState<Customer | null>(null)
const load = async () => {
try {
const data = await getCustomers()
setCustomers(data)
} catch {
toast.error("Failed to load customers")
} finally {
setLoading(false)
}
}
React.useEffect(() => { load() }, [])
const handleCreate = () => {
setEditing(null)
setDialogOpen(true)
}
const handleEdit = (customer: Customer) => {
setEditing(customer)
setDialogOpen(true)
}
const handleDelete = async (id: string) => {
const result = await deleteCustomer(id)
if (result.success) {
toast.success("Customer deleted")
await load()
} else {
toast.error(result.error || "Failed to delete customer")
}
}
const handleSubmit = async (data: {
name: string
company: string
email: string
phone: string
address: string
notes: string
}) => {
if (editing) {
const result = await updateCustomer(editing.id, data)
if (result.success) {
toast.success("Customer updated")
} else {
toast.error(result.error || "Failed to update customer")
return
}
} else {
const result = await createCustomer(data)
if (result.success) {
toast.success("Customer created")
} else {
toast.error(result.error || "Failed to create customer")
return
}
}
setDialogOpen(false)
await load()
}
if (loading) {
return (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Customers
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage customer accounts
</p>
</div>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
return (
<>
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Customers
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage customer accounts
</p>
</div>
<Button onClick={handleCreate} className="w-full sm:w-auto">
<IconPlus className="mr-2 size-4" />
Add Customer
</Button>
</div>
{customers.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No customers yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first customer to start tracking contacts and invoices.
</p>
</div>
) : (
<CustomersTable
customers={customers}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</div>
<CustomerDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
initialData={editing}
onSubmit={handleSubmit}
/>
</>
)
}

View File

@ -0,0 +1,441 @@
"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { useSearchParams, useRouter } from "next/navigation"
import { toast } from "sonner"
import { getCustomers } from "@/app/actions/customers"
import { getVendors } from "@/app/actions/vendors"
import { getProjects } from "@/app/actions/projects"
import {
getInvoices,
createInvoice,
updateInvoice,
deleteInvoice,
} from "@/app/actions/invoices"
import {
getVendorBills,
createVendorBill,
updateVendorBill,
deleteVendorBill,
} from "@/app/actions/vendor-bills"
import {
getPayments,
createPayment,
updatePayment,
deletePayment,
} from "@/app/actions/payments"
import {
getCreditMemos,
createCreditMemo,
updateCreditMemo,
deleteCreditMemo,
} from "@/app/actions/credit-memos"
import type { Customer, Vendor, Project } from "@/db/schema"
import type {
Invoice,
VendorBill,
Payment,
CreditMemo,
} from "@/db/schema-netsuite"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { InvoicesTable } from "@/components/financials/invoices-table"
import { InvoiceDialog } from "@/components/financials/invoice-dialog"
import { VendorBillsTable } from "@/components/financials/vendor-bills-table"
import { VendorBillDialog } from "@/components/financials/vendor-bill-dialog"
import { PaymentsTable } from "@/components/financials/payments-table"
import { PaymentDialog } from "@/components/financials/payment-dialog"
import { CreditMemosTable } from "@/components/financials/credit-memos-table"
import { CreditMemoDialog } from "@/components/financials/credit-memo-dialog"
type Tab = "invoices" | "bills" | "payments" | "credit-memos"
export default function FinancialsPage() {
return (
<React.Suspense fallback={
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Financials
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Invoices, bills, payments, and credit memos
</p>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
}>
<FinancialsContent />
</React.Suspense>
)
}
function FinancialsContent() {
const searchParams = useSearchParams()
const router = useRouter()
const initialTab = (searchParams.get("tab") as Tab) || "invoices"
const [tab, setTab] = React.useState<Tab>(initialTab)
const [loading, setLoading] = React.useState(true)
const [customersList, setCustomersList] = React.useState<Customer[]>([])
const [vendorsList, setVendorsList] = React.useState<Vendor[]>([])
const [projectsList, setProjectsList] = React.useState<Project[]>([])
const [invoicesList, setInvoicesList] = React.useState<Invoice[]>([])
const [billsList, setBillsList] = React.useState<VendorBill[]>([])
const [paymentsList, setPaymentsList] = React.useState<Payment[]>([])
const [memosList, setMemosList] = React.useState<CreditMemo[]>([])
const [invoiceDialogOpen, setInvoiceDialogOpen] = React.useState(false)
const [editingInvoice, setEditingInvoice] =
React.useState<Invoice | null>(null)
const [billDialogOpen, setBillDialogOpen] = React.useState(false)
const [editingBill, setEditingBill] =
React.useState<VendorBill | null>(null)
const [paymentDialogOpen, setPaymentDialogOpen] = React.useState(false)
const [editingPayment, setEditingPayment] =
React.useState<Payment | null>(null)
const [memoDialogOpen, setMemoDialogOpen] = React.useState(false)
const [editingMemo, setEditingMemo] =
React.useState<CreditMemo | null>(null)
const loadAll = async () => {
try {
const [c, v, p, inv, bills, pay, cm] = await Promise.all([
getCustomers(),
getVendors(),
getProjects(),
getInvoices(),
getVendorBills(),
getPayments(),
getCreditMemos(),
])
setCustomersList(c)
setVendorsList(v)
setProjectsList(p as Project[])
setInvoicesList(inv)
setBillsList(bills)
setPaymentsList(pay)
setMemosList(cm)
} catch {
toast.error("Failed to load financial data")
} finally {
setLoading(false)
}
}
React.useEffect(() => { loadAll() }, [])
const handleTabChange = (value: string) => {
setTab(value as Tab)
router.replace(`/dashboard/financials?tab=${value}`, { scroll: false })
}
const customerMap = React.useMemo(
() =>
Object.fromEntries(customersList.map((c) => [c.id, c.name])),
[customersList]
)
const vendorMap = React.useMemo(
() =>
Object.fromEntries(vendorsList.map((v) => [v.id, v.name])),
[vendorsList]
)
const projectMap = React.useMemo(
() =>
Object.fromEntries(projectsList.map((p) => [p.id, p.name])),
[projectsList]
)
// invoice handlers
const handleInvoiceSubmit = async (data: Parameters<typeof createInvoice>[0] & { lineItems: string; subtotal: number; tax: number; total: number; amountPaid: number; amountDue: number }) => {
if (editingInvoice) {
const result = await updateInvoice(editingInvoice.id, data)
if (result.success) toast.success("Invoice updated")
else { toast.error(result.error || "Failed"); return }
} else {
const result = await createInvoice(data)
if (result.success) toast.success("Invoice created")
else { toast.error(result.error || "Failed"); return }
}
setInvoiceDialogOpen(false)
await loadAll()
}
const handleDeleteInvoice = async (id: string) => {
const result = await deleteInvoice(id)
if (result.success) { toast.success("Invoice deleted"); await loadAll() }
else toast.error(result.error || "Failed")
}
// bill handlers
const handleBillSubmit = async (data: Parameters<typeof createVendorBill>[0] & { lineItems: string; subtotal: number; tax: number; total: number; amountPaid: number; amountDue: number }) => {
if (editingBill) {
const result = await updateVendorBill(editingBill.id, data)
if (result.success) toast.success("Bill updated")
else { toast.error(result.error || "Failed"); return }
} else {
const result = await createVendorBill(data)
if (result.success) toast.success("Bill created")
else { toast.error(result.error || "Failed"); return }
}
setBillDialogOpen(false)
await loadAll()
}
const handleDeleteBill = async (id: string) => {
const result = await deleteVendorBill(id)
if (result.success) { toast.success("Bill deleted"); await loadAll() }
else toast.error(result.error || "Failed")
}
// payment handlers
const handlePaymentSubmit = async (data: Parameters<typeof createPayment>[0] & { paymentType: string; amount: number; paymentDate: string }) => {
if (editingPayment) {
const result = await updatePayment(editingPayment.id, data)
if (result.success) toast.success("Payment updated")
else { toast.error(result.error || "Failed"); return }
} else {
const result = await createPayment(data)
if (result.success) toast.success("Payment created")
else { toast.error(result.error || "Failed"); return }
}
setPaymentDialogOpen(false)
await loadAll()
}
const handleDeletePayment = async (id: string) => {
const result = await deletePayment(id)
if (result.success) { toast.success("Payment deleted"); await loadAll() }
else toast.error(result.error || "Failed")
}
// credit memo handlers
const handleMemoSubmit = async (data: Parameters<typeof createCreditMemo>[0] & { lineItems: string; total: number; amountApplied: number; amountRemaining: number }) => {
if (editingMemo) {
const result = await updateCreditMemo(editingMemo.id, data)
if (result.success) toast.success("Credit memo updated")
else { toast.error(result.error || "Failed"); return }
} else {
const result = await createCreditMemo(data)
if (result.success) toast.success("Credit memo created")
else { toast.error(result.error || "Failed"); return }
}
setMemoDialogOpen(false)
await loadAll()
}
const handleDeleteMemo = async (id: string) => {
const result = await deleteCreditMemo(id)
if (result.success) { toast.success("Credit memo deleted"); await loadAll() }
else toast.error(result.error || "Failed")
}
if (loading) {
return (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Financials
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Invoices, bills, payments, and credit memos
</p>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
return (
<>
<div className="flex-1 space-y-6 p-4 sm:p-6 md:p-8 pt-6">
<div className="space-y-1">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Financials
</h2>
<p className="text-xs sm:text-sm text-muted-foreground">
Invoices, bills, payments, and credit memos
</p>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="overflow-x-auto min-w-0">
<TabsList className="w-max sm:w-auto">
<TabsTrigger value="invoices" className="text-xs sm:text-sm shrink-0">
Invoices
</TabsTrigger>
<TabsTrigger value="bills" className="text-xs sm:text-sm shrink-0">
Bills
</TabsTrigger>
<TabsTrigger value="payments" className="text-xs sm:text-sm shrink-0">
Payments
</TabsTrigger>
<TabsTrigger value="credit-memos" className="text-xs sm:text-sm shrink-0">
<span className="sm:hidden">Credits</span>
<span className="hidden sm:inline">Credit Memos</span>
</TabsTrigger>
</TabsList>
</div>
{tab === "invoices" && (
<Button
onClick={() => {
setEditingInvoice(null)
setInvoiceDialogOpen(true)
}}
size="sm"
className="w-full sm:w-auto h-9"
>
<IconPlus className="mr-2 size-4" />
New Invoice
</Button>
)}
{tab === "bills" && (
<Button
onClick={() => {
setEditingBill(null)
setBillDialogOpen(true)
}}
size="sm"
className="w-full sm:w-auto h-9"
>
<IconPlus className="mr-2 size-4" />
New Bill
</Button>
)}
{tab === "payments" && (
<Button
onClick={() => {
setEditingPayment(null)
setPaymentDialogOpen(true)
}}
size="sm"
className="w-full sm:w-auto h-9"
>
<IconPlus className="mr-2 size-4" />
New Payment
</Button>
)}
{tab === "credit-memos" && (
<Button
onClick={() => {
setEditingMemo(null)
setMemoDialogOpen(true)
}}
size="sm"
className="w-full sm:w-auto h-9"
>
<IconPlus className="mr-2 size-4" />
New Credit Memo
</Button>
)}
</div>
<TabsContent value="invoices" className="mt-4">
<InvoicesTable
invoices={invoicesList}
customerMap={customerMap}
projectMap={projectMap}
onEdit={(inv) => {
setEditingInvoice(inv)
setInvoiceDialogOpen(true)
}}
onDelete={handleDeleteInvoice}
/>
</TabsContent>
<TabsContent value="bills" className="mt-4">
<VendorBillsTable
bills={billsList}
vendorMap={vendorMap}
projectMap={projectMap}
onEdit={(bill) => {
setEditingBill(bill)
setBillDialogOpen(true)
}}
onDelete={handleDeleteBill}
/>
</TabsContent>
<TabsContent value="payments" className="mt-4">
<PaymentsTable
payments={paymentsList}
customerMap={customerMap}
vendorMap={vendorMap}
projectMap={projectMap}
onEdit={(pay) => {
setEditingPayment(pay)
setPaymentDialogOpen(true)
}}
onDelete={handleDeletePayment}
/>
</TabsContent>
<TabsContent value="credit-memos" className="mt-4">
<CreditMemosTable
creditMemos={memosList}
customerMap={customerMap}
onEdit={(memo) => {
setEditingMemo(memo)
setMemoDialogOpen(true)
}}
onDelete={handleDeleteMemo}
/>
</TabsContent>
</Tabs>
</div>
<InvoiceDialog
open={invoiceDialogOpen}
onOpenChange={setInvoiceDialogOpen}
initialData={editingInvoice}
customers={customersList}
projects={projectsList as Project[]}
onSubmit={handleInvoiceSubmit}
/>
<VendorBillDialog
open={billDialogOpen}
onOpenChange={setBillDialogOpen}
initialData={editingBill}
vendors={vendorsList}
projects={projectsList as Project[]}
onSubmit={handleBillSubmit}
/>
<PaymentDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
initialData={editingPayment}
customers={customersList}
vendors={vendorsList}
projects={projectsList as Project[]}
onSubmit={handlePaymentSubmit}
/>
<CreditMemoDialog
open={memoDialogOpen}
onOpenChange={setMemoDialogOpen}
initialData={editingMemo}
customers={customersList}
projects={projectsList as Project[]}
onSubmit={handleMemoSubmit}
/>
</>
)
}

147
src/app/dashboard/vendors/page.tsx vendored Executable file
View File

@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { toast } from "sonner"
import {
getVendors,
createVendor,
updateVendor,
deleteVendor,
} from "@/app/actions/vendors"
import type { Vendor } from "@/db/schema"
import { Button } from "@/components/ui/button"
import { VendorsTable } from "@/components/financials/vendors-table"
import { VendorDialog } from "@/components/financials/vendor-dialog"
export default function VendorsPage() {
const [vendors, setVendors] = React.useState<Vendor[]>([])
const [loading, setLoading] = React.useState(true)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editing, setEditing] = React.useState<Vendor | null>(null)
const load = async () => {
try {
const data = await getVendors()
setVendors(data)
} catch {
toast.error("Failed to load vendors")
} finally {
setLoading(false)
}
}
React.useEffect(() => { load() }, [])
const handleCreate = () => {
setEditing(null)
setDialogOpen(true)
}
const handleEdit = (vendor: Vendor) => {
setEditing(vendor)
setDialogOpen(true)
}
const handleDelete = async (id: string) => {
const result = await deleteVendor(id)
if (result.success) {
toast.success("Vendor deleted")
await load()
} else {
toast.error(result.error || "Failed to delete vendor")
}
}
const handleSubmit = async (data: {
name: string
category: string
email: string
phone: string
address: string
}) => {
if (editing) {
const result = await updateVendor(editing.id, data)
if (result.success) {
toast.success("Vendor updated")
} else {
toast.error(result.error || "Failed to update vendor")
return
}
} else {
const result = await createVendor(data)
if (result.success) {
toast.success("Vendor created")
} else {
toast.error(result.error || "Failed to create vendor")
return
}
}
setDialogOpen(false)
await load()
}
if (loading) {
return (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Vendors
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage vendor relationships
</p>
</div>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
return (
<>
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Vendors
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage vendor relationships
</p>
</div>
<Button onClick={handleCreate} className="w-full sm:w-auto">
<IconPlus className="mr-2 size-4" />
Add Vendor
</Button>
</div>
{vendors.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No vendors yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first vendor to manage subcontractors, suppliers, and bills.
</p>
</div>
) : (
<VendorsTable
vendors={vendors}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</div>
<VendorDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
initialData={editing}
onSubmit={handleSubmit}
/>
</>
)
}

View File

@ -0,0 +1,220 @@
"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 { CreditMemo } from "@/db/schema-netsuite"
import type { Customer, Project } from "@/db/schema"
const CREDIT_MEMO_STATUSES = ["draft", "applied", "void"] as const
interface CreditMemoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: CreditMemo | null
customers: Customer[]
projects: Project[]
onSubmit: (data: {
customerId: string
projectId: string | null
memoNumber: string
status: string
issueDate: string
memo: string
lineItems: string
total: number
amountApplied: number
amountRemaining: number
}) => void
}
export function CreditMemoDialog({
open,
onOpenChange,
initialData,
customers,
projects,
onSubmit,
}: CreditMemoDialogProps) {
const [customerId, setCustomerId] = React.useState("")
const [projectId, setProjectId] = React.useState("")
const [memoNumber, setMemoNumber] = React.useState("")
const [status, setStatus] = React.useState("draft")
const [issueDate, setIssueDate] = React.useState("")
const [memoText, setMemoText] = React.useState("")
const [lines, setLines] = React.useState<LineItem[]>([])
React.useEffect(() => {
if (initialData) {
setCustomerId(initialData.customerId)
setProjectId(initialData.projectId ?? "")
setMemoNumber(initialData.memoNumber ?? "")
setStatus(initialData.status)
setIssueDate(initialData.issueDate)
setMemoText(initialData.memo ?? "")
setLines(
initialData.lineItems ? JSON.parse(initialData.lineItems) : []
)
} else {
setCustomerId("")
setProjectId("")
setMemoNumber("")
setStatus("draft")
setIssueDate(new Date().toISOString().split("T")[0])
setMemoText("")
setLines([])
}
}, [initialData, open])
const total = lines.reduce((s, l) => s + l.amount, 0)
const amountApplied = initialData?.amountApplied ?? 0
const amountRemaining = total - amountApplied
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!customerId || !issueDate) return
onSubmit({
customerId,
projectId: projectId || null,
memoNumber,
status,
issueDate,
memo: memoText,
lineItems: JSON.stringify(lines),
total,
amountApplied,
amountRemaining,
})
}
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">Memo #</Label>
<Input
className="h-9"
value={memoNumber}
onChange={(e) => setMemoNumber(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>
{CREDIT_MEMO_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</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">Memo</Label>
<Textarea
value={memoText}
onChange={(e) => setMemoText(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>
)
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={initialData ? "Edit Credit Memo" : "New Credit Memo"}
className="max-w-2xl"
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody pages={[page1, page2, page3]} />
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{initialData ? "Save Changes" : "Create Credit Memo"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}

View File

@ -0,0 +1,352 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { CreditMemo } from "@/db/schema-netsuite"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatCurrency } from "@/lib/utils"
const STATUS_VARIANT: Record<string, "secondary" | "default" | "outline"> = {
draft: "secondary",
applied: "default",
void: "outline",
}
interface CreditMemosTableProps {
creditMemos: CreditMemo[]
customerMap: Record<string, string>
onEdit?: (memo: CreditMemo) => void
onDelete?: (id: string) => void
}
export function CreditMemosTable({
creditMemos,
customerMap,
onEdit,
onDelete,
}: CreditMemosTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const columns: ColumnDef<CreditMemo>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "memoNumber",
header: "Memo #",
cell: ({ row }) => (
<span className="font-medium">
{row.getValue("memoNumber") || "-"}
</span>
),
},
{
id: "customer",
header: "Customer",
cell: ({ row }) =>
customerMap[row.original.customerId] ?? "-",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge variant={STATUS_VARIANT[status] ?? "secondary"}>
{status}
</Badge>
)
},
},
{
accessorKey: "issueDate",
header: "Issue Date",
cell: ({ row }) =>
new Date(row.getValue("issueDate") as string).toLocaleDateString(),
},
{
accessorKey: "total",
header: "Total",
cell: ({ row }) => formatCurrency(row.getValue("total") as number),
},
{
accessorKey: "amountApplied",
header: "Applied",
cell: ({ row }) =>
formatCurrency(row.getValue("amountApplied") as number),
},
{
accessorKey: "amountRemaining",
header: "Remaining",
cell: ({ row }) =>
formatCurrency(row.getValue("amountRemaining") as number),
},
{
id: "actions",
cell: ({ row }) => {
const memo = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(memo)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(memo.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: creditMemos,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No credit memos yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Create a credit memo to adjust a customer balance.
</p>
</div>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search by memo number..."
value={
(table.getColumn("memoNumber")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("memoNumber")?.setFilterValue(e.target.value)
}
className="w-full"
/>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const memo = row.original
const status = memo.status
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{memo.memoNumber || "-"}
</p>
<Badge
variant={STATUS_VARIANT[status] ?? "secondary"}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{status}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{customerMap[memo.customerId] ?? "-"}
{memo.amountRemaining > 0 && ` · ${formatCurrency(memo.amountRemaining)} remaining`}
</p>
</div>
<span className="text-sm font-semibold shrink-0">
{formatCurrency(memo.total)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(memo)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(memo.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<Input
placeholder="Search by memo number..."
value={
(table.getColumn("memoNumber")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("memoNumber")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No credit memos found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,169 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import type { Customer } from "@/db/schema"
interface CustomerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Customer | null
onSubmit: (data: {
name: string
company: string
email: string
phone: string
address: string
notes: string
}) => void
}
export function CustomerDialog({
open,
onOpenChange,
initialData,
onSubmit,
}: CustomerDialogProps) {
const [name, setName] = React.useState("")
const [company, setCompany] = React.useState("")
const [email, setEmail] = React.useState("")
const [phone, setPhone] = React.useState("")
const [address, setAddress] = React.useState("")
const [notes, setNotes] = React.useState("")
React.useEffect(() => {
if (initialData) {
setName(initialData.name)
setCompany(initialData.company ?? "")
setEmail(initialData.email ?? "")
setPhone(initialData.phone ?? "")
setAddress(initialData.address ?? "")
setNotes(initialData.notes ?? "")
} else {
setName("")
setCompany("")
setEmail("")
setPhone("")
setAddress("")
setNotes("")
}
}, [initialData, open])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
onSubmit({
name: name.trim(),
company: company.trim(),
email: email.trim(),
phone: phone.trim(),
address: address.trim(),
notes: notes.trim(),
})
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={initialData ? "Edit Customer" : "Add Customer"}
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody>
<div className="space-y-1.5">
<Label htmlFor="cust-name" className="text-xs">
Name *
</Label>
<Input
id="cust-name"
className="h-9"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-company" className="text-xs">
Company
</Label>
<Input
id="cust-company"
className="h-9"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-email" className="text-xs">
Email
</Label>
<Input
id="cust-email"
type="email"
className="h-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-phone" className="text-xs">
Phone
</Label>
<Input
id="cust-phone"
className="h-9"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-address" className="text-xs">
Address
</Label>
<Textarea
id="cust-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-notes" className="text-xs">
Notes
</Label>
<Textarea
id="cust-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
</ResponsiveDialogBody>
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{initialData ? "Save Changes" : "Create Customer"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}

View File

@ -0,0 +1,374 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { Customer } from "@/db/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface CustomersTableProps {
customers: Customer[]
onEdit?: (customer: Customer) => void
onDelete?: (id: string) => void
}
export function CustomersTable({
customers,
onEdit,
onDelete,
}: CustomersTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "name", desc: false },
])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const sortKey = React.useMemo(() => {
if (!sorting.length) return "name-asc"
const s = sorting[0]
if (s.id === "name") return s.desc ? "name-desc" : "name-asc"
if (s.id === "createdAt") return s.desc ? "newest" : "oldest"
return "name-asc"
}, [sorting])
const handleSort = (value: string) => {
switch (value) {
case "name-asc":
setSorting([{ id: "name", desc: false }])
break
case "name-desc":
setSorting([{ id: "name", desc: true }])
break
case "newest":
setSorting([{ id: "createdAt", desc: true }])
break
case "oldest":
setSorting([{ id: "createdAt", desc: false }])
break
}
}
const columns: ColumnDef<Customer>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "company",
header: "Company",
cell: ({ row }) =>
row.getValue("company") || (
<span className="text-muted-foreground">-</span>
),
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.getValue("email") || (
<span className="text-muted-foreground">-</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) =>
row.getValue("phone") || (
<span className="text-muted-foreground">-</span>
),
},
{
accessorKey: "netsuiteId",
header: "NetSuite ID",
cell: ({ row }) => {
const nsId = row.getValue("netsuiteId") as string | null
if (!nsId) return <span className="text-muted-foreground">-</span>
return <Badge variant="outline">{nsId}</Badge>
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
const d = row.getValue("createdAt") as string
return new Date(d).toLocaleDateString()
},
},
{
id: "actions",
cell: ({ row }) => {
const customer = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">open menu</span>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(customer)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: customers,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No customers yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first customer to start tracking contacts and invoices.
</p>
</div>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search customers..."
value={
(table.getColumn("name")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full"
/>
<Select value={sortKey} onValueChange={handleSort}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name A-Z</SelectItem>
<SelectItem value="name-desc">Name Z-A</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
</SelectContent>
</Select>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const c = row.original
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{c.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{[c.company, c.email, c.phone]
.filter(Boolean)
.join(" \u00b7 ") || "No contact info"}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(c)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(c.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<Input
placeholder="Search customers..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No customers found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,279 @@
"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>
)
}

View File

@ -0,0 +1,368 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { Invoice } from "@/db/schema-netsuite"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatCurrency } from "@/lib/utils"
const STATUS_VARIANT: Record<string, "secondary" | "default" | "outline" | "destructive"> = {
draft: "secondary",
sent: "default",
paid: "outline",
overdue: "destructive",
partially_paid: "default",
void: "secondary",
}
interface InvoicesTableProps {
invoices: Invoice[]
customerMap: Record<string, string>
projectMap: Record<string, string>
onEdit?: (invoice: Invoice) => void
onDelete?: (id: string) => void
}
export function InvoicesTable({
invoices,
customerMap,
projectMap,
onEdit,
onDelete,
}: InvoicesTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const columns: ColumnDef<Invoice>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "invoiceNumber",
header: "Invoice #",
cell: ({ row }) => (
<span className="font-medium">
{row.getValue("invoiceNumber") || "-"}
</span>
),
},
{
id: "customer",
header: "Customer",
cell: ({ row }) =>
customerMap[row.original.customerId] ?? "-",
},
{
id: "project",
header: "Project",
cell: ({ row }) =>
row.original.projectId
? (projectMap[row.original.projectId] ?? "-")
: "-",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge variant={STATUS_VARIANT[status] ?? "secondary"}>
{status.replace("_", " ")}
</Badge>
)
},
},
{
accessorKey: "issueDate",
header: "Issue Date",
cell: ({ row }) =>
new Date(row.getValue("issueDate") as string).toLocaleDateString(),
},
{
accessorKey: "dueDate",
header: "Due Date",
cell: ({ row }) => {
const d = row.getValue("dueDate") as string | null
return d ? new Date(d).toLocaleDateString() : "-"
},
},
{
accessorKey: "total",
header: "Total",
cell: ({ row }) => formatCurrency(row.getValue("total") as number),
},
{
accessorKey: "amountDue",
header: "Amount Due",
cell: ({ row }) =>
formatCurrency(row.getValue("amountDue") as number),
},
{
id: "actions",
cell: ({ row }) => {
const invoice = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(invoice)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(invoice.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: invoices,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No invoices yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Create an invoice to bill a customer for project work.
</p>
</div>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search by invoice number..."
value={
(table.getColumn("invoiceNumber")?.getFilterValue() as string) ??
""
}
onChange={(e) =>
table.getColumn("invoiceNumber")?.setFilterValue(e.target.value)
}
className="w-full"
/>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const inv = row.original
const status = inv.status
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{inv.invoiceNumber || "-"}
</p>
<Badge
variant={STATUS_VARIANT[status] ?? "secondary"}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{status.replace("_", " ")}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{customerMap[inv.customerId] ?? "-"}
{inv.dueDate && ` · Due ${new Date(inv.dueDate).toLocaleDateString()}`}
</p>
</div>
<span className="text-sm font-semibold shrink-0">
{formatCurrency(inv.total)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(inv)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(inv.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<Input
placeholder="Search by invoice number..."
value={
(table.getColumn("invoiceNumber")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("invoiceNumber")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No invoices found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,198 @@
"use client"
import * as React from "react"
import { IconPlus, IconX } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { formatCurrency } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
export type LineItem = {
description: string
quantity: number
rate: number
amount: number
}
interface LineItemsEditorProps {
value: LineItem[]
onChange: (items: LineItem[]) => void
}
export function LineItemsEditor({ value, onChange }: LineItemsEditorProps) {
const isMobile = useIsMobile()
const addLine = () => {
onChange([...value, { description: "", quantity: 1, rate: 0, amount: 0 }])
}
const removeLine = (index: number) => {
onChange(value.filter((_, i) => i !== index))
}
const updateLine = (
index: number,
field: keyof LineItem,
raw: string
) => {
const updated = value.map((item, i) => {
if (i !== index) return item
const next = { ...item }
if (field === "description") {
next.description = raw
} else if (field === "quantity") {
next.quantity = parseFloat(raw) || 0
next.amount = next.quantity * next.rate
} else if (field === "rate") {
next.rate = parseFloat(raw) || 0
next.amount = next.quantity * next.rate
}
return next
})
onChange(updated)
}
const subtotal = value.reduce((sum, item) => sum + item.amount, 0)
// Mobile: card-based layout
if (isMobile) {
return (
<div className="space-y-3">
{value.map((item, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-2.5 relative"
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 size-7"
onClick={() => removeLine(index)}
>
<IconX className="size-3.5" />
</Button>
<div className="space-y-1.5 pr-8">
<Label className="text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => updateLine(index, "description", e.target.value)}
placeholder="Item description"
className="h-9 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="space-y-1.5">
<Label className="text-xs">Qty</Label>
<Input
type="number"
min={0}
step="any"
value={item.quantity || ""}
onChange={(e) => updateLine(index, "quantity", e.target.value)}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rate</Label>
<Input
type="number"
min={0}
step="any"
value={item.rate || ""}
onChange={(e) => updateLine(index, "rate", e.target.value)}
className="h-9 text-sm"
/>
</div>
</div>
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-muted-foreground">Amount:</span>
<span className="font-semibold">{formatCurrency(item.amount)}</span>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addLine}
className="w-full h-9"
>
<IconPlus className="mr-2 size-4" />
Add Line Item
</Button>
<div className="flex items-center justify-between pt-2 text-sm border-t">
<span className="font-medium">Subtotal:</span>
<span className="font-semibold">{formatCurrency(subtotal)}</span>
</div>
</div>
)
}
// Desktop: table layout
return (
<div className="space-y-2">
<div className="grid grid-cols-[1fr_80px_100px_100px_32px] gap-2 text-xs font-medium text-muted-foreground">
<span>Description</span>
<span>Qty</span>
<span>Rate</span>
<span>Amount</span>
<span />
</div>
{value.map((item, index) => (
<div
key={index}
className="grid grid-cols-[1fr_80px_100px_100px_32px] gap-2 items-center"
>
<Input
value={item.description}
onChange={(e) => updateLine(index, "description", e.target.value)}
placeholder="Line item description"
className="h-9"
/>
<Input
type="number"
min={0}
step="any"
value={item.quantity || ""}
onChange={(e) => updateLine(index, "quantity", e.target.value)}
className="h-9"
/>
<Input
type="number"
min={0}
step="any"
value={item.rate || ""}
onChange={(e) => updateLine(index, "rate", e.target.value)}
className="h-9"
/>
<span className="text-sm px-2">{formatCurrency(item.amount)}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => removeLine(index)}
>
<IconX className="size-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" size="sm" onClick={addLine} className="h-9">
<IconPlus className="mr-1 size-4" />
Add Line
</Button>
<span className="text-sm font-medium">
Subtotal: {formatCurrency(subtotal)}
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,268 @@
"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 type { Payment } from "@/db/schema-netsuite"
import type { Customer, Vendor, Project } from "@/db/schema"
const PAYMENT_METHODS = [
"check",
"ach",
"wire",
"credit_card",
"cash",
"other",
] as const
interface PaymentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Payment | null
customers: Customer[]
vendors: Vendor[]
projects: Project[]
onSubmit: (data: {
paymentType: string
customerId: string | null
vendorId: string | null
projectId: string | null
amount: number
paymentDate: string
paymentMethod: string
referenceNumber: string
memo: string
}) => void
}
export function PaymentDialog({
open,
onOpenChange,
initialData,
customers,
vendors,
projects,
onSubmit,
}: PaymentDialogProps) {
const [paymentType, setPaymentType] = React.useState("received")
const [customerId, setCustomerId] = React.useState("")
const [vendorId, setVendorId] = React.useState("")
const [projectId, setProjectId] = React.useState("")
const [amount, setAmount] = React.useState(0)
const [paymentDate, setPaymentDate] = React.useState("")
const [paymentMethod, setPaymentMethod] = React.useState("")
const [referenceNumber, setReferenceNumber] = React.useState("")
const [memo, setMemo] = React.useState("")
React.useEffect(() => {
if (initialData) {
setPaymentType(initialData.paymentType)
setCustomerId(initialData.customerId ?? "")
setVendorId(initialData.vendorId ?? "")
setProjectId(initialData.projectId ?? "")
setAmount(initialData.amount)
setPaymentDate(initialData.paymentDate)
setPaymentMethod(initialData.paymentMethod ?? "")
setReferenceNumber(initialData.referenceNumber ?? "")
setMemo(initialData.memo ?? "")
} else {
setPaymentType("received")
setCustomerId("")
setVendorId("")
setProjectId("")
setAmount(0)
setPaymentDate(new Date().toISOString().split("T")[0])
setPaymentMethod("")
setReferenceNumber("")
setMemo("")
}
}, [initialData, open])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!amount || !paymentDate) return
onSubmit({
paymentType,
customerId: paymentType === "received" ? (customerId || null) : null,
vendorId: paymentType === "sent" ? (vendorId || null) : null,
projectId: projectId || null,
amount,
paymentDate,
paymentMethod,
referenceNumber,
memo,
})
}
const page1 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Type *</Label>
<Select value={paymentType} onValueChange={setPaymentType}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="received">Received</SelectItem>
<SelectItem value="sent">Sent</SelectItem>
</SelectContent>
</Select>
</div>
{paymentType === "received" && (
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<Select value={customerId || "none"} onValueChange={(v) => setCustomerId(v === "none" ? "" : v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{paymentType === "sent" && (
<div className="space-y-1.5">
<Label className="text-xs">Vendor</Label>
<Select value={vendorId || "none"} onValueChange={(v) => setVendorId(v === "none" ? "" : v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.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>
</>
)
const page2 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Amount *</Label>
<Input
type="number"
className="h-9"
min={0}
step="any"
value={amount || ""}
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Date *</Label>
<DatePicker
value={paymentDate}
onChange={setPaymentDate}
placeholder="Select date"
/>
</div>
</>
)
const page3 = (
<>
<div className="space-y-1.5">
<Label className="text-xs">Method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Reference #</Label>
<Input
className="h-9"
value={referenceNumber}
onChange={(e) => setReferenceNumber(e.target.value)}
/>
</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>
</>
)
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={initialData ? "Edit Payment" : "New Payment"}
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody pages={[page1, page2, page3]} />
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{initialData ? "Save Changes" : "Create Payment"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}

View File

@ -0,0 +1,374 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { Payment } from "@/db/schema-netsuite"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatCurrency } from "@/lib/utils"
interface PaymentsTableProps {
payments: Payment[]
customerMap: Record<string, string>
vendorMap: Record<string, string>
projectMap: Record<string, string>
onEdit?: (payment: Payment) => void
onDelete?: (id: string) => void
}
export function PaymentsTable({
payments,
customerMap,
vendorMap,
projectMap,
onEdit,
onDelete,
}: PaymentsTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const columns: ColumnDef<Payment>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "referenceNumber",
header: "Reference #",
cell: ({ row }) => (
<span className="font-medium">
{row.getValue("referenceNumber") || "-"}
</span>
),
},
{
accessorKey: "paymentType",
header: "Type",
cell: ({ row }) => {
const type = row.getValue("paymentType") as string
return (
<Badge variant={type === "received" ? "default" : "secondary"}>
{type}
</Badge>
)
},
},
{
id: "counterparty",
header: "Customer/Vendor",
cell: ({ row }) => {
const p = row.original
if (p.customerId) return customerMap[p.customerId] ?? "-"
if (p.vendorId) return vendorMap[p.vendorId] ?? "-"
return "-"
},
},
{
id: "project",
header: "Project",
cell: ({ row }) =>
row.original.projectId
? (projectMap[row.original.projectId] ?? "-")
: "-",
},
{
accessorKey: "amount",
header: "Amount",
cell: ({ row }) => formatCurrency(row.getValue("amount") as number),
},
{
accessorKey: "paymentDate",
header: "Date",
cell: ({ row }) =>
new Date(
row.getValue("paymentDate") as string
).toLocaleDateString(),
},
{
accessorKey: "paymentMethod",
header: "Method",
cell: ({ row }) => {
const m = row.getValue("paymentMethod") as string | null
return m ? m.replace("_", " ") : "-"
},
},
{
id: "actions",
cell: ({ row }) => {
const payment = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(payment)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(payment.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: payments,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No payments yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Record a payment to track money in or out.
</p>
</div>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search by reference number..."
value={
(table.getColumn("referenceNumber")?.getFilterValue() as string) ??
""
}
onChange={(e) =>
table
.getColumn("referenceNumber")
?.setFilterValue(e.target.value)
}
className="w-full"
/>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const p = row.original
const counterparty = p.customerId
? customerMap[p.customerId]
: p.vendorId
? vendorMap[p.vendorId]
: null
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{p.referenceNumber || "-"}
</p>
<Badge
variant={
p.paymentType === "received"
? "default"
: "secondary"
}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{p.paymentType}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{counterparty ?? "-"}
{` · ${new Date(p.paymentDate).toLocaleDateString()}`}
</p>
</div>
<span className="text-sm font-semibold shrink-0">
{formatCurrency(p.amount)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(p)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(p.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<Input
placeholder="Search by reference number..."
value={
(table.getColumn("referenceNumber")?.getFilterValue() as string) ??
""
}
onChange={(e) =>
table
.getColumn("referenceNumber")
?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No payments found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,276 @@
"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 { VendorBill } from "@/db/schema-netsuite"
import type { Vendor, Project } from "@/db/schema"
const BILL_STATUSES = [
"pending",
"approved",
"partially_paid",
"paid",
"void",
] as const
interface VendorBillDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: VendorBill | null
vendors: Vendor[]
projects: Project[]
onSubmit: (data: {
vendorId: string
projectId: string | null
billNumber: string
status: string
billDate: string
dueDate: string
memo: string
lineItems: string
subtotal: number
tax: number
total: number
amountPaid: number
amountDue: number
}) => void
}
export function VendorBillDialog({
open,
onOpenChange,
initialData,
vendors,
projects,
onSubmit,
}: VendorBillDialogProps) {
const [vendorId, setVendorId] = React.useState("")
const [projectId, setProjectId] = React.useState("")
const [billNumber, setBillNumber] = React.useState("")
const [status, setStatus] = React.useState("pending")
const [billDate, setBillDate] = 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) {
setVendorId(initialData.vendorId)
setProjectId(initialData.projectId ?? "")
setBillNumber(initialData.billNumber ?? "")
setStatus(initialData.status)
setBillDate(initialData.billDate)
setDueDate(initialData.dueDate ?? "")
setMemo(initialData.memo ?? "")
setTax(initialData.tax)
setLines(
initialData.lineItems ? JSON.parse(initialData.lineItems) : []
)
} else {
setVendorId("")
setProjectId("")
setBillNumber("")
setStatus("pending")
setBillDate(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 (!vendorId || !billDate) return
onSubmit({
vendorId,
projectId: projectId || null,
billNumber,
status,
billDate,
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">Vendor *</Label>
<Select value={vendorId} onValueChange={setVendorId}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
<SelectContent>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.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">Bill #</Label>
<Input
className="h-9"
value={billNumber}
onChange={(e) => setBillNumber(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>
{BILL_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">Bill Date *</Label>
<DatePicker
value={billDate}
onChange={setBillDate}
placeholder="Select bill 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 Bill" : "New Bill"}
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 Bill"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}

View File

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { VendorBill } from "@/db/schema-netsuite"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatCurrency } from "@/lib/utils"
const STATUS_VARIANT: Record<string, "secondary" | "default" | "outline"> = {
pending: "secondary",
approved: "default",
paid: "outline",
partially_paid: "default",
void: "secondary",
}
interface VendorBillsTableProps {
bills: VendorBill[]
vendorMap: Record<string, string>
projectMap: Record<string, string>
onEdit?: (bill: VendorBill) => void
onDelete?: (id: string) => void
}
export function VendorBillsTable({
bills,
vendorMap,
projectMap,
onEdit,
onDelete,
}: VendorBillsTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const columns: ColumnDef<VendorBill>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "billNumber",
header: "Bill #",
cell: ({ row }) => (
<span className="font-medium">
{row.getValue("billNumber") || "-"}
</span>
),
},
{
id: "vendor",
header: "Vendor",
cell: ({ row }) => vendorMap[row.original.vendorId] ?? "-",
},
{
id: "project",
header: "Project",
cell: ({ row }) =>
row.original.projectId
? (projectMap[row.original.projectId] ?? "-")
: "-",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge variant={STATUS_VARIANT[status] ?? "secondary"}>
{status.replace("_", " ")}
</Badge>
)
},
},
{
accessorKey: "billDate",
header: "Bill Date",
cell: ({ row }) =>
new Date(row.getValue("billDate") as string).toLocaleDateString(),
},
{
accessorKey: "dueDate",
header: "Due Date",
cell: ({ row }) => {
const d = row.getValue("dueDate") as string | null
return d ? new Date(d).toLocaleDateString() : "-"
},
},
{
accessorKey: "total",
header: "Total",
cell: ({ row }) => formatCurrency(row.getValue("total") as number),
},
{
accessorKey: "amountDue",
header: "Amount Due",
cell: ({ row }) =>
formatCurrency(row.getValue("amountDue") as number),
},
{
id: "actions",
cell: ({ row }) => {
const bill = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(bill)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(bill.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: bills,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No bills yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Record a vendor bill to track what you owe.
</p>
</div>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search by bill number..."
value={
(table.getColumn("billNumber")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("billNumber")?.setFilterValue(e.target.value)
}
className="w-full"
/>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const bill = row.original
const status = bill.status
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{bill.billNumber || "-"}
</p>
<Badge
variant={STATUS_VARIANT[status] ?? "secondary"}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{status.replace("_", " ")}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{vendorMap[bill.vendorId] ?? "-"}
{bill.dueDate && ` · Due ${new Date(bill.dueDate).toLocaleDateString()}`}
</p>
</div>
<span className="text-sm font-semibold shrink-0">
{formatCurrency(bill.total)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(bill)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(bill.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<Input
placeholder="Search by bill number..."
value={
(table.getColumn("billNumber")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("billNumber")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No bills found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,174 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
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 type { Vendor } from "@/db/schema"
const VENDOR_CATEGORIES = [
"Subcontractor",
"Supplier",
"Equipment",
"Material",
"Consultant",
"Other",
] as const
interface VendorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Vendor | null
onSubmit: (data: {
name: string
category: string
email: string
phone: string
address: string
}) => void
}
export function VendorDialog({
open,
onOpenChange,
initialData,
onSubmit,
}: VendorDialogProps) {
const [name, setName] = React.useState("")
const [category, setCategory] = React.useState("Subcontractor")
const [email, setEmail] = React.useState("")
const [phone, setPhone] = React.useState("")
const [address, setAddress] = React.useState("")
React.useEffect(() => {
if (initialData) {
setName(initialData.name)
setCategory(initialData.category)
setEmail(initialData.email ?? "")
setPhone(initialData.phone ?? "")
setAddress(initialData.address ?? "")
} else {
setName("")
setCategory("Subcontractor")
setEmail("")
setPhone("")
setAddress("")
}
}, [initialData, open])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
onSubmit({
name: name.trim(),
category,
email: email.trim(),
phone: phone.trim(),
address: address.trim(),
})
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={initialData ? "Edit Vendor" : "Add Vendor"}
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody>
<div className="space-y-1.5">
<Label htmlFor="vendor-name" className="text-xs">
Name *
</Label>
<Input
id="vendor-name"
className="h-9"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-category" className="text-xs">
Category *
</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="vendor-category" className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-email" className="text-xs">
Email
</Label>
<Input
id="vendor-email"
type="email"
className="h-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-phone" className="text-xs">
Phone
</Label>
<Input
id="vendor-phone"
className="h-9"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-address" className="text-xs">
Address
</Label>
<Textarea
id="vendor-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
</ResponsiveDialogBody>
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{initialData ? "Save Changes" : "Create Vendor"}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialog>
)
}

View File

@ -0,0 +1,420 @@
"use client"
import * as React from "react"
import { IconDotsVertical } from "@tabler/icons-react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table"
import type { Vendor } from "@/db/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface VendorsTableProps {
vendors: Vendor[]
onEdit?: (vendor: Vendor) => void
onDelete?: (id: string) => void
}
export function VendorsTable({
vendors,
onEdit,
onDelete,
}: VendorsTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "name", desc: false },
])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const sortKey = React.useMemo(() => {
if (!sorting.length) return "name-asc"
const s = sorting[0]
if (s.id === "name") return s.desc ? "name-desc" : "name-asc"
if (s.id === "createdAt") return s.desc ? "newest" : "oldest"
return "name-asc"
}, [sorting])
const handleSort = (value: string) => {
switch (value) {
case "name-asc":
setSorting([{ id: "name", desc: false }])
break
case "name-desc":
setSorting([{ id: "name", desc: true }])
break
case "newest":
setSorting([{ id: "createdAt", desc: true }])
break
case "oldest":
setSorting([{ id: "createdAt", desc: false }])
break
}
}
const columns: ColumnDef<Vendor>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "category",
header: "Category",
cell: ({ row }) => (
<Badge variant="secondary">{row.getValue("category")}</Badge>
),
filterFn: (row, id, value) => {
if (!value || value === "all") return true
return row.getValue(id) === value
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.getValue("email") || (
<span className="text-muted-foreground">-</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) =>
row.getValue("phone") || (
<span className="text-muted-foreground">-</span>
),
},
{
id: "actions",
cell: ({ row }) => {
const vendor = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">open menu</span>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(vendor)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(vendor.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: vendors,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
})
const emptyState = (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No vendors yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first vendor to manage subcontractors, suppliers, and bills.
</p>
</div>
)
const categoryFilter = (
<Select
value={
(table.getColumn("category")?.getFilterValue() as string) ?? "all"
}
onValueChange={(v) =>
table.getColumn("category")?.setFilterValue(v === "all" ? "" : v)
}
>
<SelectTrigger className="flex-1 sm:w-[180px] sm:flex-none">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="Subcontractor">Subcontractor</SelectItem>
<SelectItem value="Supplier">Supplier</SelectItem>
<SelectItem value="Equipment">Equipment</SelectItem>
<SelectItem value="Material">Material</SelectItem>
<SelectItem value="Consultant">Consultant</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
)
if (isMobile) {
const rows = table.getRowModel().rows
return (
<div className="space-y-3">
<Input
placeholder="Search vendors..."
value={
(table.getColumn("name")?.getFilterValue() as string) ?? ""
}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full"
/>
<div className="grid grid-cols-2 gap-2">
<Select value={sortKey} onValueChange={handleSort}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name A-Z</SelectItem>
<SelectItem value="name-desc">Name Z-A</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
</SelectContent>
</Select>
<Select
value={
(table.getColumn("category")?.getFilterValue() as string) ??
"all"
}
onValueChange={(v) =>
table
.getColumn("category")
?.setFilterValue(v === "all" ? "" : v)
}
>
<SelectTrigger>
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="Subcontractor">Subcontractor</SelectItem>
<SelectItem value="Supplier">Supplier</SelectItem>
<SelectItem value="Equipment">Equipment</SelectItem>
<SelectItem value="Material">Material</SelectItem>
<SelectItem value="Consultant">Consultant</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
{rows.length ? (
<div className="rounded-md border divide-y">
{rows.map((row) => {
const v = row.original
return (
<div
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
{v.name}
</p>
<Badge
variant="secondary"
className="shrink-0 text-[10px] px-1.5 py-0"
>
{v.category}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{[v.email, v.phone].filter(Boolean).join(" \u00b7 ") ||
"No contact info"}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
>
<IconDotsVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit?.(v)}>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(v.id)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
</div>
) : (
emptyState
)}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Search vendors..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
/>
{categoryFilter}
</div>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No vendors found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,128 @@
"use client"
import * as React from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
getConflicts,
resolveConflict,
} from "@/app/actions/netsuite-sync"
import { toast } from "sonner"
interface Conflict {
id: string
localTable: string
localRecordId: string
netsuiteInternalId: string | null
conflictData: string | null
}
export function ConflictDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [conflicts, setConflicts] = React.useState<Conflict[]>([])
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
if (!open) return
getConflicts().then(result => {
if (result.success) {
setConflicts(result.conflicts as Conflict[])
}
setLoading(false)
})
}, [open])
const handleResolve = async (
metaId: string,
resolution: "use_local" | "use_remote"
) => {
const result = await resolveConflict(metaId, resolution)
if (result.success) {
setConflicts(prev => prev.filter(c => c.id !== metaId))
toast.success("Conflict resolved")
} else {
toast.error(result.error ?? "Failed to resolve conflict")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sync Conflicts</DialogTitle>
<DialogDescription>
Records that were modified in both Compass and NetSuite.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="space-y-2 py-4">
<div className="bg-muted h-12 animate-pulse rounded" />
<div className="bg-muted h-12 animate-pulse rounded" />
</div>
) : conflicts.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
No conflicts to resolve.
</p>
) : (
<div className="max-h-80 space-y-3 overflow-y-auto">
{conflicts.map(conflict => (
<div
key={conflict.id}
className="border-border space-y-2 rounded border p-3"
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">
{conflict.localTable}
</span>
<Badge variant="destructive" className="ml-2">
Conflict
</Badge>
</div>
<span className="text-muted-foreground text-xs">
NS#{conflict.netsuiteInternalId}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handleResolve(conflict.id, "use_local")
}
>
Keep Local
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleResolve(conflict.id, "use_remote")
}
>
Use NetSuite
</Button>
</div>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,125 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import {
initiateNetSuiteOAuth,
disconnectNetSuite,
getNetSuiteConnectionStatus,
} from "@/app/actions/netsuite-sync"
export function NetSuiteConnectionStatus() {
const [status, setStatus] = React.useState<{
configured: boolean
connected: boolean
accountId: string | null
} | null>(null)
const [loading, setLoading] = React.useState(true)
const [actionLoading, setActionLoading] = React.useState(false)
React.useEffect(() => {
getNetSuiteConnectionStatus().then(s => {
setStatus(s)
setLoading(false)
})
}, [])
const handleConnect = async () => {
setActionLoading(true)
const result = await initiateNetSuiteOAuth()
if (result.success && result.authorizeUrl) {
// set state cookie before redirecting
document.cookie = `netsuite_oauth_state=${result.state}; path=/; max-age=600; secure; samesite=lax`
window.location.href = result.authorizeUrl
}
setActionLoading(false)
}
const handleDisconnect = async () => {
setActionLoading(true)
await disconnectNetSuite()
setStatus(prev =>
prev ? { ...prev, connected: false } : prev
)
setActionLoading(false)
}
if (loading) {
return (
<div className="space-y-3">
<div className="bg-muted h-4 w-48 animate-pulse rounded" />
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
</div>
)
}
if (!status?.configured) {
return (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
NetSuite integration is not configured.
Set the required environment variables to enable it.
</p>
<div className="text-muted-foreground space-y-1 text-xs font-mono">
<div>NETSUITE_CLIENT_ID</div>
<div>NETSUITE_CLIENT_SECRET</div>
<div>NETSUITE_ACCOUNT_ID</div>
<div>NETSUITE_REDIRECT_URI</div>
<div>NETSUITE_TOKEN_ENCRYPTION_KEY</div>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">NetSuite</span>
<Badge
variant={status.connected ? "default" : "secondary"}
>
{status.connected ? "Connected" : "Not connected"}
</Badge>
</div>
{status.accountId && (
<p className="text-muted-foreground text-xs">
Account: {status.accountId}
</p>
)}
</div>
{status.connected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnect}
disabled={actionLoading}
>
Disconnect
</Button>
) : (
<Button
size="sm"
onClick={handleConnect}
disabled={actionLoading}
>
Connect
</Button>
)}
</div>
<Separator />
{status.connected && (
<p className="text-muted-foreground text-sm">
OAuth 2.0 connected. Customers, vendors, and financial
records will sync automatically.
</p>
)}
</div>
)
}

View File

@ -0,0 +1,68 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
syncCustomers,
syncVendors,
} from "@/app/actions/netsuite-sync"
import { toast } from "sonner"
export function SyncControls() {
const [syncing, setSyncing] = React.useState<string | null>(null)
const handleSync = async (
type: string,
action: () => Promise<{
success: boolean
error?: string
pulled?: number
created?: number
updated?: number
}>
) => {
setSyncing(type)
const result = await action()
if (result.success) {
toast.success(
`Synced ${type}: ${result.pulled ?? 0} pulled, ${result.created ?? 0} created, ${result.updated ?? 0} updated`
)
} else {
toast.error(result.error ?? "Sync failed")
}
setSyncing(null)
}
return (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
Manually trigger a sync for each record type.
Delta sync runs automatically every 15 minutes.
</p>
<Separator />
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={syncing !== null}
onClick={() => handleSync("customers", syncCustomers)}
>
{syncing === "customers" ? "Syncing..." : "Sync Customers"}
</Button>
<Button
variant="outline"
size="sm"
disabled={syncing !== null}
onClick={() => handleSync("vendors", syncVendors)}
>
{syncing === "vendors" ? "Syncing..." : "Sync Vendors"}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,19 @@
"use client"
import { Badge } from "@/components/ui/badge"
import type { SyncStatus } from "@/lib/netsuite/client/types"
const STATUS_CONFIG: Record<
SyncStatus,
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
> = {
synced: { label: "Synced", variant: "default" },
pending_push: { label: "Pending", variant: "secondary" },
conflict: { label: "Conflict", variant: "destructive" },
error: { label: "Error", variant: "destructive" },
}
export function SyncStatusBadge({ status }: { status: SyncStatus }) {
const config = STATUS_CONFIG[status]
return <Badge variant={config.variant}>{config.label}</Badge>
}

76
src/lib/netsuite/auth/crypto.ts Executable file
View File

@ -0,0 +1,76 @@
// AES-256-GCM encryption for OAuth tokens at rest in D1.
// uses Web Crypto API (available in Cloudflare Workers).
const ALGORITHM = "AES-GCM"
const KEY_LENGTH = 256
const IV_LENGTH = 12
const TAG_LENGTH = 128
async function deriveKey(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "PBKDF2" },
false,
["deriveKey"]
)
// static salt is fine here - the encryption key itself
// is the secret, and each ciphertext gets a unique IV
const salt = encoder.encode("compass-netsuite-tokens")
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: ALGORITHM, length: KEY_LENGTH },
false,
["encrypt", "decrypt"]
)
}
export async function encrypt(
plaintext: string,
secret: string
): Promise<string> {
const key = await deriveKey(secret)
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoder = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
encoder.encode(plaintext)
)
// pack as iv:ciphertext in base64
const packed = new Uint8Array(iv.length + ciphertext.byteLength)
packed.set(iv)
packed.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...packed))
}
export async function decrypt(
encoded: string,
secret: string
): Promise<string> {
const key = await deriveKey(secret)
const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
const iv = packed.slice(0, IV_LENGTH)
const ciphertext = packed.slice(IV_LENGTH)
const plaintext = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
key,
ciphertext
)
return new TextDecoder().decode(plaintext)
}

View File

@ -0,0 +1,120 @@
import { getAuthBaseUrl, type NetSuiteConfig } from "../config"
export interface OAuthTokens {
accessToken: string
refreshToken: string
expiresIn: number
tokenType: string
issuedAt: number
}
const SCOPES = ["rest_webservices", "suite_analytics"]
export function getAuthorizeUrl(
config: NetSuiteConfig,
state: string
): string {
const base = getAuthBaseUrl(config.accountId)
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: SCOPES.join(" "),
state,
})
return `${base}/app/login/oauth2/authorize.nl?${params.toString()}`
}
export async function exchangeCodeForTokens(
config: NetSuiteConfig,
code: string
): Promise<OAuthTokens> {
const base = getAuthBaseUrl(config.accountId)
const tokenUrl = `${base}/app/login/oauth2/token.nl`
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
})
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuth(config.clientId, config.clientSecret),
},
body: body.toString(),
})
if (!response.ok) {
const text = await response.text()
throw new Error(
`Token exchange failed (${response.status}): ${text}`
)
}
const data = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
issuedAt: Date.now(),
}
}
export async function refreshAccessToken(
config: NetSuiteConfig,
refreshToken: string
): Promise<OAuthTokens> {
const base = getAuthBaseUrl(config.accountId)
const tokenUrl = `${base}/app/login/oauth2/token.nl`
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
})
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: basicAuth(config.clientId, config.clientSecret),
},
body: body.toString(),
})
if (!response.ok) {
const text = await response.text()
throw new Error(
`Token refresh failed (${response.status}): ${text}`
)
}
const data = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
token_type: string
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
issuedAt: Date.now(),
}
}
function basicAuth(clientId: string, clientSecret: string): string {
return `Basic ${btoa(`${clientId}:${clientSecret}`)}`
}

View File

@ -0,0 +1,148 @@
import { eq } from "drizzle-orm"
import type { NetSuiteConfig } from "../config"
import { encrypt, decrypt } from "./crypto"
import {
refreshAccessToken,
type OAuthTokens,
} from "./oauth-client"
import { netsuiteAuth } from "@/db/schema-netsuite"
import type { DrizzleD1Database } from "drizzle-orm/d1"
// refresh at 80% of token lifetime to avoid edge-case expiry
const REFRESH_THRESHOLD = 0.8
export class TokenManager {
private config: NetSuiteConfig
private db: DrizzleD1Database
private cachedTokens: OAuthTokens | null = null
constructor(config: NetSuiteConfig, db: DrizzleD1Database) {
this.config = config
this.db = db
}
async getAccessToken(): Promise<string> {
const tokens = await this.loadTokens()
if (!tokens) {
throw new Error(
"No NetSuite tokens found. Complete OAuth setup first."
)
}
if (this.shouldRefresh(tokens)) {
const refreshed = await this.refresh(tokens.refreshToken)
return refreshed.accessToken
}
return tokens.accessToken
}
async storeTokens(tokens: OAuthTokens): Promise<void> {
const encryptedAccess = await encrypt(
tokens.accessToken,
this.config.tokenEncryptionKey
)
const encryptedRefresh = await encrypt(
tokens.refreshToken,
this.config.tokenEncryptionKey
)
const existing = await this.db
.select()
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
const now = new Date().toISOString()
const values = {
accountId: this.config.accountId,
accessTokenEncrypted: encryptedAccess,
refreshTokenEncrypted: encryptedRefresh,
expiresIn: tokens.expiresIn,
tokenType: tokens.tokenType,
issuedAt: tokens.issuedAt,
updatedAt: now,
}
if (existing.length > 0) {
await this.db
.update(netsuiteAuth)
.set(values)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
} else {
await this.db.insert(netsuiteAuth).values({
id: crypto.randomUUID(),
...values,
createdAt: now,
})
}
this.cachedTokens = tokens
}
async hasTokens(): Promise<boolean> {
const row = await this.db
.select({ id: netsuiteAuth.id })
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
return row.length > 0
}
async clearTokens(): Promise<void> {
await this.db
.delete(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
this.cachedTokens = null
}
private shouldRefresh(tokens: OAuthTokens): boolean {
const elapsed = Date.now() - tokens.issuedAt
const threshold = tokens.expiresIn * 1000 * REFRESH_THRESHOLD
return elapsed >= threshold
}
private async refresh(
refreshToken: string
): Promise<OAuthTokens> {
const tokens = await refreshAccessToken(
this.config,
refreshToken
)
await this.storeTokens(tokens)
return tokens
}
private async loadTokens(): Promise<OAuthTokens | null> {
if (this.cachedTokens) return this.cachedTokens
const rows = await this.db
.select()
.from(netsuiteAuth)
.where(eq(netsuiteAuth.accountId, this.config.accountId))
.limit(1)
if (rows.length === 0) return null
const row = rows[0]
const accessToken = await decrypt(
row.accessTokenEncrypted,
this.config.tokenEncryptionKey
)
const refreshToken = await decrypt(
row.refreshTokenEncrypted,
this.config.tokenEncryptionKey
)
this.cachedTokens = {
accessToken,
refreshToken,
expiresIn: row.expiresIn,
tokenType: row.tokenType,
issuedAt: row.issuedAt,
}
return this.cachedTokens
}
}

View File

@ -0,0 +1,146 @@
import { TokenManager } from "../auth/token-manager"
import { NetSuiteError, classifyError } from "./errors"
import { ConcurrencyLimiter } from "../rate-limiter/concurrency-limiter"
interface RetryConfig {
maxRetries: number
baseDelay: number
maxDelay: number
}
const DEFAULT_RETRY: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
}
// circuit breaker: after N consecutive failures, pause requests
const CIRCUIT_BREAKER_THRESHOLD = 5
const CIRCUIT_BREAKER_RESET_MS = 60000
export class BaseClient {
private tokenManager: TokenManager
private limiter: ConcurrencyLimiter
private retryConfig: RetryConfig
private consecutiveFailures = 0
private circuitOpenUntil = 0
constructor(
tokenManager: TokenManager,
limiter: ConcurrencyLimiter,
retryConfig?: Partial<RetryConfig>
) {
this.tokenManager = tokenManager
this.limiter = limiter
this.retryConfig = { ...DEFAULT_RETRY, ...retryConfig }
}
async request<T>(
url: string,
init: RequestInit = {}
): Promise<T> {
this.checkCircuitBreaker()
return this.limiter.execute(() =>
this.requestWithRetry<T>(url, init)
)
}
private async requestWithRetry<T>(
url: string,
init: RequestInit,
attempt = 0
): Promise<T> {
try {
const token = await this.tokenManager.getAccessToken()
const headers = new Headers(init.headers)
headers.set("Authorization", `Bearer ${token}`)
headers.set("Content-Type", "application/json")
headers.set("Accept", "application/json")
const response = await fetch(url, { ...init, headers })
if (response.ok) {
this.consecutiveFailures = 0
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
const body = await response.json().catch(
() => response.text()
)
const classified = classifyError(response.status, body)
const error = new NetSuiteError({
...classified,
statusCode: response.status,
raw: body,
})
if (error.retryable && attempt < this.retryConfig.maxRetries) {
const delay = this.getBackoffDelay(attempt, error.retryAfter)
await sleep(delay)
return this.requestWithRetry<T>(url, init, attempt + 1)
}
this.recordFailure()
throw error
} catch (err) {
if (err instanceof NetSuiteError) throw err
// network errors
if (attempt < this.retryConfig.maxRetries) {
const delay = this.getBackoffDelay(attempt, null)
await sleep(delay)
return this.requestWithRetry<T>(url, init, attempt + 1)
}
this.recordFailure()
throw new NetSuiteError({
message: err instanceof Error
? err.message
: "Network error",
category: "network",
raw: err,
})
}
}
private getBackoffDelay(
attempt: number,
retryAfter: number | null
): number {
if (retryAfter) return retryAfter
const delay = this.retryConfig.baseDelay * Math.pow(2, attempt)
const jitter = Math.random() * 0.3 * delay
return Math.min(delay + jitter, this.retryConfig.maxDelay)
}
private checkCircuitBreaker(): void {
if (Date.now() < this.circuitOpenUntil) {
throw new NetSuiteError({
message: "Circuit breaker open - too many consecutive failures",
category: "server_error",
})
}
}
private recordFailure(): void {
this.consecutiveFailures++
if (this.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
this.circuitOpenUntil = Date.now() + CIRCUIT_BREAKER_RESET_MS
this.consecutiveFailures = 0
}
}
resetCircuitBreaker(): void {
this.consecutiveFailures = 0
this.circuitOpenUntil = 0
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

169
src/lib/netsuite/client/errors.ts Executable file
View File

@ -0,0 +1,169 @@
export type ErrorCategory =
| "rate_limited"
| "auth_expired"
| "auth_invalid"
| "permission_denied"
| "validation"
| "not_found"
| "server_error"
| "timeout"
| "network"
| "unknown"
export class NetSuiteError extends Error {
readonly category: ErrorCategory
readonly statusCode: number | null
readonly retryable: boolean
readonly retryAfter: number | null
readonly raw: unknown
constructor(opts: {
message: string
category: ErrorCategory
statusCode?: number | null
retryAfter?: number | null
raw?: unknown
}) {
super(opts.message)
this.name = "NetSuiteError"
this.category = opts.category
this.statusCode = opts.statusCode ?? null
this.retryAfter = opts.retryAfter ?? null
this.raw = opts.raw ?? null
this.retryable = isRetryable(opts.category)
}
}
function isRetryable(category: ErrorCategory): boolean {
switch (category) {
case "rate_limited":
case "timeout":
case "server_error":
case "network":
return true
case "auth_expired":
// retryable after token refresh
return true
case "auth_invalid":
case "permission_denied":
case "validation":
case "not_found":
case "unknown":
return false
}
}
// netsuite returns misleading errors. a 401 might be a timeout,
// a "field doesn't exist" might be a permission issue, and a
// "Invalid Login Attempt" might be rate limiting on SOAP.
export function classifyError(
status: number,
body: unknown
): { category: ErrorCategory; message: string; retryAfter: number | null } {
const bodyStr = typeof body === "string"
? body
: JSON.stringify(body ?? "")
if (status === 429) {
const retryAfter = parseRetryAfter(body)
return {
category: "rate_limited",
message: "Rate limited by NetSuite",
retryAfter,
}
}
if (status === 401) {
// netsuite sometimes returns 401 for timeouts
if (bodyStr.includes("timeout") || bodyStr.includes("ETIMEDOUT")) {
return {
category: "timeout",
message: "Request timed out (disguised as 401)",
retryAfter: null,
}
}
if (bodyStr.includes("Invalid Login Attempt")) {
return {
category: "rate_limited",
message: "Rate limited (disguised as auth error)",
retryAfter: 5000,
}
}
return {
category: "auth_expired",
message: "Authentication expired or invalid",
retryAfter: null,
}
}
if (status === 403) {
// "field doesn't exist" often means permission denied
if (bodyStr.includes("does not exist") || bodyStr.includes("not exist")) {
return {
category: "permission_denied",
message: "Permission denied (disguised as missing field)",
retryAfter: null,
}
}
return {
category: "permission_denied",
message: "Access forbidden",
retryAfter: null,
}
}
if (status === 404) {
return {
category: "not_found",
message: "Record not found",
retryAfter: null,
}
}
if (status === 400) {
return {
category: "validation",
message: extractValidationMessage(body),
retryAfter: null,
}
}
if (status >= 500) {
return {
category: "server_error",
message: `NetSuite server error (${status})`,
retryAfter: null,
}
}
return {
category: "unknown",
message: `Unexpected status ${status}: ${bodyStr.slice(0, 200)}`,
retryAfter: null,
}
}
function parseRetryAfter(body: unknown): number | null {
if (typeof body === "object" && body !== null) {
const obj = body as Record<string, unknown>
if (typeof obj["Retry-After"] === "number") {
return obj["Retry-After"] * 1000
}
}
return 5000 // default 5s backoff
}
function extractValidationMessage(body: unknown): string {
if (typeof body === "object" && body !== null) {
const obj = body as Record<string, unknown>
if (typeof obj.title === "string") return obj.title
if (typeof obj["o:errorDetails"] === "object") {
const details = obj["o:errorDetails"] as Array<{
detail?: string
}>
if (details?.[0]?.detail) return details[0].detail
}
if (typeof obj.message === "string") return obj.message
}
return "Validation error"
}

View File

@ -0,0 +1,137 @@
import { getRecordUrl } from "../config"
import { BaseClient } from "./base-client"
import type {
NetSuiteListResponse,
NetSuiteRecord,
NetSuiteRequestOptions,
} from "./types"
export class RecordClient {
private client: BaseClient
private accountId: string
constructor(client: BaseClient, accountId: string) {
this.client = client
this.accountId = accountId
}
async get<T extends NetSuiteRecord>(
recordType: string,
id: string,
options?: NetSuiteRequestOptions
): Promise<T> {
const url = this.buildUrl(recordType, id, options)
return this.client.request<T>(url)
}
async list<T extends NetSuiteRecord>(
recordType: string,
options?: NetSuiteRequestOptions
): Promise<NetSuiteListResponse<T>> {
const url = this.buildUrl(recordType, undefined, options)
return this.client.request<NetSuiteListResponse<T>>(url)
}
async listAll<T extends NetSuiteRecord>(
recordType: string,
options?: NetSuiteRequestOptions
): Promise<T[]> {
const all: T[] = []
let offset = 0
const limit = options?.limit ?? 1000
while (true) {
const response = await this.list<T>(recordType, {
...options,
limit,
offset,
})
all.push(...response.items)
if (!response.hasMore) break
offset += limit
}
return all
}
async create(
recordType: string,
data: Record<string, unknown>,
idempotencyKey?: string
): Promise<{ id: string }> {
const url = getRecordUrl(this.accountId, recordType)
const headers: Record<string, string> = {}
if (idempotencyKey) {
headers["X-NetSuite-Idempotency-Key"] = idempotencyKey
}
return this.client.request(url, {
method: "POST",
body: JSON.stringify(data),
headers,
})
}
async update(
recordType: string,
id: string,
data: Record<string, unknown>
): Promise<void> {
const url = getRecordUrl(this.accountId, recordType, id)
await this.client.request(url, {
method: "PATCH",
body: JSON.stringify(data),
})
}
async remove(
recordType: string,
id: string
): Promise<void> {
const url = getRecordUrl(this.accountId, recordType, id)
await this.client.request(url, { method: "DELETE" })
}
// netsuite record transformations (e.g. sales order -> invoice)
async transform(
sourceType: string,
sourceId: string,
targetType: string,
data?: Record<string, unknown>
): Promise<{ id: string }> {
const url = `${getRecordUrl(this.accountId, sourceType, sourceId)}/!transform/${targetType}`
return this.client.request(url, {
method: "POST",
body: data ? JSON.stringify(data) : undefined,
})
}
private buildUrl(
recordType: string,
id?: string,
options?: NetSuiteRequestOptions
): string {
const base = getRecordUrl(this.accountId, recordType, id)
const params = new URLSearchParams()
if (options?.fields?.length) {
params.set("fields", options.fields.join(","))
}
if (options?.query) {
params.set("q", options.query)
}
if (options?.limit) {
params.set("limit", String(options.limit))
}
if (options?.offset) {
params.set("offset", String(options.offset))
}
if (options?.expandSubResources) {
params.set("expandSubResources", "true")
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}
}

View File

@ -0,0 +1,69 @@
import { getSuiteQLUrl } from "../config"
import { BaseClient } from "./base-client"
import type { SuiteQLResponse } from "./types"
const MAX_ROWS = 100000
const DEFAULT_PAGE_SIZE = 1000
export class SuiteQLClient {
private client: BaseClient
private accountId: string
constructor(client: BaseClient, accountId: string) {
this.client = client
this.accountId = accountId
}
async query<T extends Record<string, unknown>>(
sql: string,
limit = DEFAULT_PAGE_SIZE,
offset = 0
): Promise<SuiteQLResponse & { items: T[] }> {
const url = this.buildUrl(limit, offset)
return this.client.request(url, {
method: "POST",
headers: { Prefer: "transient" },
body: JSON.stringify({ q: sql }),
})
}
async queryAll<T extends Record<string, unknown>>(
sql: string,
pageSize = DEFAULT_PAGE_SIZE
): Promise<T[]> {
const all: T[] = []
let offset = 0
while (offset < MAX_ROWS) {
const response = await this.query<T>(sql, pageSize, offset)
all.push(...response.items)
if (!response.hasMore) break
offset += pageSize
}
return all
}
// convenience: get a single value from a count/sum query
async queryScalar<T>(sql: string): Promise<T | null> {
const response = await this.query(sql, 1)
if (response.items.length === 0) return null
const row = response.items[0]
const keys = Object.keys(row)
if (keys.length === 0) return null
return row[keys[0]] as T
}
private buildUrl(limit: number, offset: number): string {
const base = getSuiteQLUrl(this.accountId)
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
})
return `${base}?${params.toString()}`
}
}

138
src/lib/netsuite/client/types.ts Executable file
View File

@ -0,0 +1,138 @@
// netsuite API response types
export interface NetSuiteListResponse<T = NetSuiteRecord> {
links: NetSuiteLink[]
count: number
hasMore: boolean
totalResults?: number
items: T[]
offset: number
}
export interface NetSuiteLink {
rel: string
href: string
}
export interface NetSuiteRecord {
id: string
links?: NetSuiteLink[]
[key: string]: unknown
}
// core record types we care about
export interface NetSuiteCustomer extends NetSuiteRecord {
companyName: string
email?: string
phone?: string
entityId: string
isInactive: boolean
dateCreated: string
lastModifiedDate: string
subsidiary?: { id: string; refName: string }
defaultAddress?: string
terms?: { id: string; refName: string }
}
export interface NetSuiteVendor extends NetSuiteRecord {
companyName: string
email?: string
phone?: string
entityId: string
isInactive: boolean
category?: { id: string; refName: string }
dateCreated: string
lastModifiedDate: string
defaultAddress?: string
}
export interface NetSuiteJob extends NetSuiteRecord {
entityId: string
companyName: string
parent?: { id: string; refName: string }
jobStatus?: { id: string; refName: string }
startDate?: string
projectedEndDate?: string
dateCreated: string
lastModifiedDate: string
}
export interface NetSuiteInvoice extends NetSuiteRecord {
tranId: string
entity: { id: string; refName: string }
tranDate: string
dueDate?: string
status: { id: string; refName: string }
total: number
amountRemaining: number
memo?: string
job?: { id: string; refName: string }
lastModifiedDate: string
item?: { items: NetSuiteLineItem[] }
}
export interface NetSuiteVendorBill extends NetSuiteRecord {
tranId: string
entity: { id: string; refName: string }
tranDate: string
dueDate?: string
status: { id: string; refName: string }
total: number
amountRemaining: number
memo?: string
job?: { id: string; refName: string }
lastModifiedDate: string
item?: { items: NetSuiteLineItem[] }
}
export interface NetSuiteLineItem {
line: number
item: { id: string; refName: string }
quantity: number
rate: number
amount: number
description?: string
}
export interface NetSuitePayment extends NetSuiteRecord {
tranId: string
entity: { id: string; refName: string }
tranDate: string
total: number
paymentMethod?: { id: string; refName: string }
memo?: string
lastModifiedDate: string
}
// suiteql response shape
export interface SuiteQLResponse {
links: NetSuiteLink[]
count: number
hasMore: boolean
totalResults?: number
items: Record<string, unknown>[]
offset: number
}
// request options
export interface NetSuiteRequestOptions {
fields?: string[]
query?: string
limit?: number
offset?: number
expandSubResources?: boolean
}
export type SyncDirection = "pull" | "push"
export type SyncStatus =
| "synced"
| "pending_push"
| "conflict"
| "error"
export type ConflictStrategy =
| "newest_wins"
| "remote_wins"
| "local_wins"
| "manual"

66
src/lib/netsuite/config.ts Executable file
View File

@ -0,0 +1,66 @@
export interface NetSuiteConfig {
accountId: string
clientId: string
clientSecret: string
redirectUri: string
tokenEncryptionKey: string
concurrencyLimit: number
}
export function getNetSuiteConfig(
env: Record<string, string | undefined>
): NetSuiteConfig {
const accountId = env.NETSUITE_ACCOUNT_ID
const clientId = env.NETSUITE_CLIENT_ID
const clientSecret = env.NETSUITE_CLIENT_SECRET
const redirectUri = env.NETSUITE_REDIRECT_URI
const tokenEncryptionKey = env.NETSUITE_TOKEN_ENCRYPTION_KEY
const concurrencyLimit = parseInt(
env.NETSUITE_CONCURRENCY_LIMIT ?? "15",
10
)
if (!accountId || !clientId || !clientSecret) {
throw new Error("Missing required NetSuite configuration")
}
if (!redirectUri) {
throw new Error("NETSUITE_REDIRECT_URI is required")
}
if (!tokenEncryptionKey) {
throw new Error("NETSUITE_TOKEN_ENCRYPTION_KEY is required")
}
return {
accountId,
clientId,
clientSecret,
redirectUri,
tokenEncryptionKey,
concurrencyLimit,
}
}
// netsuite uses different URL formats for REST vs auth,
// and sandbox has inconsistent casing/separators
export function getRestBaseUrl(accountId: string): string {
const urlId = accountId.toLowerCase().replace("_", "-")
return `https://${urlId}.suitetalk.api.netsuite.com`
}
export function getAuthBaseUrl(accountId: string): string {
const urlId = accountId.toLowerCase().replace("_", "-")
return `https://${urlId}.app.netsuite.com`
}
export function getSuiteQLUrl(accountId: string): string {
return `${getRestBaseUrl(accountId)}/services/rest/query/v1/suiteql`
}
export function getRecordUrl(
accountId: string,
recordType: string,
id?: string
): string {
const base = `${getRestBaseUrl(accountId)}/services/rest/record/v1/${recordType}`
return id ? `${base}/${id}` : base
}

View File

@ -0,0 +1,57 @@
// abstract mapper interface for bidirectional field mapping
// between local (compass D1) records and netsuite records.
export interface FieldMapping<Local, Remote> {
toRemote(local: Local): Partial<Remote>
toLocal(remote: Remote): Partial<Local>
getNetSuiteRecordType(): string
getLocalTable(): string
getSuiteQLFields(): string[]
getLastModifiedField(): string
}
export abstract class BaseMapper<
Local extends Record<string, unknown>,
Remote extends Record<string, unknown>,
> implements FieldMapping<Local, Remote> {
abstract toRemote(local: Local): Partial<Remote>
abstract toLocal(remote: Remote): Partial<Local>
abstract getNetSuiteRecordType(): string
abstract getLocalTable(): string
getSuiteQLFields(): string[] {
return ["id", "lastmodifieddate"]
}
getLastModifiedField(): string {
return "lastmodifieddate"
}
// build a SuiteQL SELECT for this entity
buildSelectQuery(
additionalWhere?: string,
limit?: number,
offset?: number
): string {
const fields = this.getSuiteQLFields().join(", ")
const table = this.getNetSuiteRecordType()
let sql = `SELECT ${fields} FROM ${table}`
if (additionalWhere) {
sql += ` WHERE ${additionalWhere}`
}
if (limit) sql += ` FETCH NEXT ${limit} ROWS ONLY`
if (offset) sql += ` OFFSET ${offset} ROWS`
return sql
}
// build a delta query filtered by last modified date
buildDeltaQuery(since: string): string {
const lastMod = this.getLastModifiedField()
return this.buildSelectQuery(
`${lastMod} > '${since}'`
)
}
}

View File

@ -0,0 +1,49 @@
import { BaseMapper } from "./base-mapper"
import type { Customer } from "@/db/schema"
import type { NetSuiteCustomer } from "../client/types"
type LocalCustomer = Customer & { updatedAt?: string | null }
export class CustomerMapper extends BaseMapper<
LocalCustomer,
NetSuiteCustomer
> {
toRemote(local: LocalCustomer): Partial<NetSuiteCustomer> {
return {
companyName: local.name,
email: local.email ?? undefined,
phone: local.phone ?? undefined,
}
}
toLocal(remote: NetSuiteCustomer): Partial<LocalCustomer> {
return {
name: remote.companyName,
email: remote.email ?? null,
phone: remote.phone ?? null,
netsuiteId: remote.id,
updatedAt: new Date().toISOString(),
}
}
getNetSuiteRecordType(): string {
return "customer"
}
getLocalTable(): string {
return "customers"
}
getSuiteQLFields(): string[] {
return [
"id",
"companyname",
"email",
"phone",
"entityid",
"isinactive",
"datecreated",
"lastmodifieddate",
]
}
}

View File

@ -0,0 +1,78 @@
import { BaseMapper } from "./base-mapper"
import type { Invoice } from "@/db/schema-netsuite"
import type { NetSuiteInvoice } from "../client/types"
export class InvoiceMapper extends BaseMapper<
Invoice,
NetSuiteInvoice
> {
toRemote(local: Invoice): Partial<NetSuiteInvoice> {
const result: Partial<NetSuiteInvoice> = {
tranDate: local.issueDate,
memo: local.memo ?? undefined,
}
if (local.dueDate) {
result.dueDate = local.dueDate
}
if (local.customerId) {
result.entity = {
id: local.customerId,
refName: "",
}
}
return result
}
toLocal(remote: NetSuiteInvoice): Partial<Invoice> {
return {
netsuiteId: remote.id,
invoiceNumber: remote.tranId,
status: mapInvoiceStatus(remote.status?.refName),
issueDate: remote.tranDate,
dueDate: remote.dueDate ?? null,
total: remote.total ?? 0,
amountDue: remote.amountRemaining ?? 0,
amountPaid: (remote.total ?? 0) - (remote.amountRemaining ?? 0),
memo: remote.memo ?? null,
updatedAt: new Date().toISOString(),
}
}
getNetSuiteRecordType(): string {
return "invoice"
}
getLocalTable(): string {
return "invoices"
}
getSuiteQLFields(): string[] {
return [
"id",
"tranid",
"entity",
"trandate",
"duedate",
"status",
"total",
"amountremaining",
"memo",
"job",
"lastmodifieddate",
]
}
}
function mapInvoiceStatus(nsStatus?: string): string {
if (!nsStatus) return "draft"
const normalized = nsStatus.toLowerCase()
if (normalized.includes("paid") && !normalized.includes("not")) {
return "paid"
}
if (normalized.includes("open")) return "sent"
if (normalized.includes("voided")) return "voided"
return "draft"
}

View File

@ -0,0 +1,53 @@
import { BaseMapper } from "./base-mapper"
import type { Project } from "@/db/schema"
import type { NetSuiteJob } from "../client/types"
export class ProjectMapper extends BaseMapper<Project, NetSuiteJob> {
toRemote(local: Project): Partial<NetSuiteJob> {
return {
companyName: local.name,
}
}
toLocal(remote: NetSuiteJob): Partial<Project> {
return {
name: remote.companyName,
netsuiteJobId: remote.id,
status: mapJobStatus(remote.jobStatus?.refName),
}
}
getNetSuiteRecordType(): string {
return "job"
}
getLocalTable(): string {
return "projects"
}
getSuiteQLFields(): string[] {
return [
"id",
"entityid",
"companyname",
"jobstatus",
"startdate",
"projectedenddate",
"datecreated",
"lastmodifieddate",
]
}
}
function mapJobStatus(nsStatus?: string): string {
if (!nsStatus) return "OPEN"
const normalized = nsStatus.toLowerCase()
if (normalized.includes("closed") || normalized.includes("complete")) {
return "CLOSED"
}
if (normalized.includes("progress") || normalized.includes("active")) {
return "IN_PROGRESS"
}
return "OPEN"
}

View File

@ -0,0 +1,78 @@
import { BaseMapper } from "./base-mapper"
import type { VendorBill } from "@/db/schema-netsuite"
import type { NetSuiteVendorBill } from "../client/types"
export class VendorBillMapper extends BaseMapper<
VendorBill,
NetSuiteVendorBill
> {
toRemote(local: VendorBill): Partial<NetSuiteVendorBill> {
const result: Partial<NetSuiteVendorBill> = {
tranDate: local.billDate,
memo: local.memo ?? undefined,
}
if (local.dueDate) {
result.dueDate = local.dueDate
}
if (local.vendorId) {
result.entity = {
id: local.vendorId,
refName: "",
}
}
return result
}
toLocal(remote: NetSuiteVendorBill): Partial<VendorBill> {
return {
netsuiteId: remote.id,
billNumber: remote.tranId,
status: mapBillStatus(remote.status?.refName),
billDate: remote.tranDate,
dueDate: remote.dueDate ?? null,
total: remote.total ?? 0,
amountDue: remote.amountRemaining ?? 0,
amountPaid: (remote.total ?? 0) - (remote.amountRemaining ?? 0),
memo: remote.memo ?? null,
updatedAt: new Date().toISOString(),
}
}
getNetSuiteRecordType(): string {
return "vendorBill"
}
getLocalTable(): string {
return "vendor_bills"
}
getSuiteQLFields(): string[] {
return [
"id",
"tranid",
"entity",
"trandate",
"duedate",
"status",
"total",
"amountremaining",
"memo",
"job",
"lastmodifieddate",
]
}
}
function mapBillStatus(nsStatus?: string): string {
if (!nsStatus) return "pending"
const normalized = nsStatus.toLowerCase()
if (normalized.includes("paid") && !normalized.includes("not")) {
return "paid"
}
if (normalized.includes("open")) return "approved"
if (normalized.includes("voided")) return "voided"
return "pending"
}

View File

@ -0,0 +1,52 @@
import { BaseMapper } from "./base-mapper"
import type { Vendor } from "@/db/schema"
import type { NetSuiteVendor } from "../client/types"
type LocalVendor = Vendor & { updatedAt?: string | null }
export class VendorMapper extends BaseMapper<
LocalVendor,
NetSuiteVendor
> {
toRemote(local: LocalVendor): Partial<NetSuiteVendor> {
return {
companyName: local.name,
email: local.email ?? undefined,
phone: local.phone ?? undefined,
}
}
toLocal(remote: NetSuiteVendor): Partial<LocalVendor> {
return {
name: remote.companyName,
email: remote.email ?? null,
phone: remote.phone ?? null,
address: remote.defaultAddress ?? null,
category: remote.category?.refName ?? "Subcontractor",
netsuiteId: remote.id,
updatedAt: new Date().toISOString(),
}
}
getNetSuiteRecordType(): string {
return "vendor"
}
getLocalTable(): string {
return "vendors"
}
getSuiteQLFields(): string[] {
return [
"id",
"companyname",
"email",
"phone",
"entityid",
"isinactive",
"category",
"datecreated",
"lastmodifieddate",
]
}
}

View File

@ -0,0 +1,82 @@
// semaphore-based concurrency limiter.
// netsuite shares a pool of 15 concurrent requests across
// ALL integrations (SOAP + REST + RESTlet). we default to
// a conservative limit and back off adaptively on 429s.
export class ConcurrencyLimiter {
private maxConcurrent: number
private running = 0
private queue: Array<{
resolve: () => void
priority: number
}> = []
constructor(maxConcurrent = 15) {
this.maxConcurrent = maxConcurrent
}
async execute<T>(
fn: () => Promise<T>,
priority = 0
): Promise<T> {
await this.acquire(priority)
try {
return await fn()
} finally {
this.release()
}
}
private acquire(priority: number): Promise<void> {
if (this.running < this.maxConcurrent) {
this.running++
return Promise.resolve()
}
return new Promise(resolve => {
this.queue.push({ resolve, priority })
this.queue.sort((a, b) => b.priority - a.priority)
})
}
private release(): void {
if (this.queue.length > 0) {
const next = this.queue.shift()!
next.resolve()
} else {
this.running--
}
}
// adaptively reduce concurrency when we hit rate limits
reduceConcurrency(): void {
if (this.maxConcurrent > 1) {
this.maxConcurrent = Math.max(
1,
Math.floor(this.maxConcurrent * 0.7)
)
}
}
// gradually restore concurrency after successful requests
restoreConcurrency(original: number): void {
if (this.maxConcurrent < original) {
this.maxConcurrent = Math.min(
original,
this.maxConcurrent + 1
)
}
}
get currentConcurrency(): number {
return this.running
}
get maxAllowed(): number {
return this.maxConcurrent
}
get queueLength(): number {
return this.queue.length
}
}

View File

@ -0,0 +1,37 @@
// priority-based FIFO request queue.
// wraps the concurrency limiter with named priorities
// so sync operations can be deprioritized vs user-triggered actions.
import { ConcurrencyLimiter } from "./concurrency-limiter"
export type RequestPriority = "critical" | "high" | "normal" | "low"
const PRIORITY_VALUES: Record<RequestPriority, number> = {
critical: 30,
high: 20,
normal: 10,
low: 0,
}
export class RequestQueue {
private limiter: ConcurrencyLimiter
constructor(limiter: ConcurrencyLimiter) {
this.limiter = limiter
}
async enqueue<T>(
fn: () => Promise<T>,
priority: RequestPriority = "normal"
): Promise<T> {
return this.limiter.execute(fn, PRIORITY_VALUES[priority])
}
get stats() {
return {
running: this.limiter.currentConcurrency,
maxAllowed: this.limiter.maxAllowed,
queued: this.limiter.queueLength,
}
}
}

View File

@ -0,0 +1,67 @@
import type { ConflictStrategy } from "../client/types"
export interface ConflictResult {
resolution: "use_local" | "use_remote" | "flag_manual"
reason: string
}
export function resolveConflict(
strategy: ConflictStrategy,
localModified: string | null,
remoteModified: string | null
): ConflictResult {
switch (strategy) {
case "remote_wins":
return {
resolution: "use_remote",
reason: "Remote wins strategy applied",
}
case "local_wins":
return {
resolution: "use_local",
reason: "Local wins strategy applied",
}
case "manual":
return {
resolution: "flag_manual",
reason: "Flagged for manual review",
}
case "newest_wins": {
if (!localModified && !remoteModified) {
return {
resolution: "use_remote",
reason: "No timestamps available, defaulting to remote",
}
}
if (!localModified) {
return {
resolution: "use_remote",
reason: "No local timestamp",
}
}
if (!remoteModified) {
return {
resolution: "use_local",
reason: "No remote timestamp",
}
}
const localTime = new Date(localModified).getTime()
const remoteTime = new Date(remoteModified).getTime()
if (remoteTime >= localTime) {
return {
resolution: "use_remote",
reason: `Remote is newer (${remoteModified} >= ${localModified})`,
}
}
return {
resolution: "use_local",
reason: `Local is newer (${localModified} > ${remoteModified})`,
}
}
}
}

View File

@ -0,0 +1,143 @@
import { eq, and } from "drizzle-orm"
import type { DrizzleD1Database } from "drizzle-orm/d1"
import { netsuiteSyncMetadata } from "@/db/schema-netsuite"
import { SuiteQLClient } from "../client/suiteql-client"
import type { BaseMapper } from "../mappers/base-mapper"
import { resolveConflict } from "./conflict-resolver"
import type { ConflictStrategy } from "../client/types"
export interface DeltaSyncResult {
pulled: number
created: number
updated: number
conflicts: number
errors: Array<{ netsuiteId: string; error: string }>
}
export async function pullDelta(
db: DrizzleD1Database,
suiteql: SuiteQLClient,
mapper: BaseMapper<
Record<string, unknown>,
Record<string, unknown>
>,
lastSyncTime: string | null,
conflictStrategy: ConflictStrategy,
upsertLocal: (
localId: string | null,
data: Record<string, unknown>
) => Promise<string>
): Promise<DeltaSyncResult> {
const result: DeltaSyncResult = {
pulled: 0,
created: 0,
updated: 0,
conflicts: 0,
errors: [],
}
const query = lastSyncTime
? mapper.buildDeltaQuery(lastSyncTime)
: mapper.buildSelectQuery()
const remoteRecords = await suiteql.queryAll(query)
result.pulled = remoteRecords.length
for (const remote of remoteRecords) {
try {
const netsuiteId = String(remote.id)
const tableName = mapper.getLocalTable()
const existingMeta = await db
.select()
.from(netsuiteSyncMetadata)
.where(
and(
eq(netsuiteSyncMetadata.localTable, tableName),
eq(netsuiteSyncMetadata.netsuiteInternalId, netsuiteId)
)
)
.limit(1)
const localData = mapper.toLocal(
remote as Record<string, unknown>
)
const remoteModified = String(
remote[mapper.getLastModifiedField()] ?? ""
)
if (existingMeta.length > 0) {
const meta = existingMeta[0]
// check for conflicts on pending_push records
if (meta.syncStatus === "pending_push") {
const conflict = resolveConflict(
conflictStrategy,
meta.lastModifiedLocal,
remoteModified
)
if (conflict.resolution === "flag_manual") {
await db
.update(netsuiteSyncMetadata)
.set({
syncStatus: "conflict",
conflictData: JSON.stringify({
remote: localData,
reason: conflict.reason,
}),
lastModifiedRemote: remoteModified,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, meta.id))
result.conflicts++
continue
}
if (conflict.resolution === "use_local") {
continue
}
}
await upsertLocal(meta.localRecordId, localData)
await db
.update(netsuiteSyncMetadata)
.set({
syncStatus: "synced",
lastSyncedAt: new Date().toISOString(),
lastModifiedRemote: remoteModified,
errorMessage: null,
retryCount: 0,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, meta.id))
result.updated++
} else {
const localId = await upsertLocal(null, localData)
const now = new Date().toISOString()
await db.insert(netsuiteSyncMetadata).values({
id: crypto.randomUUID(),
localTable: tableName,
localRecordId: localId,
netsuiteRecordType: mapper.getNetSuiteRecordType(),
netsuiteInternalId: netsuiteId,
lastSyncedAt: now,
lastModifiedRemote: remoteModified,
syncStatus: "synced",
retryCount: 0,
createdAt: now,
updatedAt: now,
})
result.created++
}
} catch (err) {
result.errors.push({
netsuiteId: String(remote.id),
error: err instanceof Error ? err.message : "Unknown error",
})
}
}
return result
}

View File

@ -0,0 +1,15 @@
// X-NetSuite-Idempotency-Key management.
// generates deterministic keys for push operations so
// retries don't create duplicate records in netsuite.
export function generateIdempotencyKey(
operation: string,
recordType: string,
localId: string,
timestamp?: number
): string {
// include a time bucket (1-hour window) so the same operation
// can be retried within the window but creates a new key after
const bucket = Math.floor((timestamp ?? Date.now()) / 3600000)
return `${operation}:${recordType}:${localId}:${bucket}`
}

128
src/lib/netsuite/sync/push.ts Executable file
View File

@ -0,0 +1,128 @@
import { eq, and } from "drizzle-orm"
import type { DrizzleD1Database } from "drizzle-orm/d1"
import { netsuiteSyncMetadata } from "@/db/schema-netsuite"
import { RecordClient } from "../client/record-client"
import type { BaseMapper } from "../mappers/base-mapper"
import { generateIdempotencyKey } from "./idempotency"
import { NetSuiteError } from "../client/errors"
export interface PushResult {
pushed: number
failed: number
errors: Array<{ localId: string; error: string }>
}
export async function pushPendingChanges(
db: DrizzleD1Database,
recordClient: RecordClient,
mapper: BaseMapper<
Record<string, unknown>,
Record<string, unknown>
>,
getLocalRecord: (id: string) => Promise<Record<string, unknown> | null>
): Promise<PushResult> {
const result: PushResult = { pushed: 0, failed: 0, errors: [] }
const tableName = mapper.getLocalTable()
const pending = await db
.select()
.from(netsuiteSyncMetadata)
.where(
and(
eq(netsuiteSyncMetadata.localTable, tableName),
eq(netsuiteSyncMetadata.syncStatus, "pending_push")
)
)
for (const meta of pending) {
try {
const local = await getLocalRecord(meta.localRecordId)
if (!local) {
await markError(db, meta.id, "Local record not found")
result.failed++
continue
}
const remoteData = mapper.toRemote(local)
const recordType = mapper.getNetSuiteRecordType()
if (meta.netsuiteInternalId) {
await recordClient.update(
recordType,
meta.netsuiteInternalId,
remoteData
)
} else {
const key = generateIdempotencyKey(
"create",
recordType,
meta.localRecordId
)
const created = await recordClient.create(
recordType,
remoteData,
key
)
await db
.update(netsuiteSyncMetadata)
.set({ netsuiteInternalId: created.id })
.where(eq(netsuiteSyncMetadata.id, meta.id))
}
await db
.update(netsuiteSyncMetadata)
.set({
syncStatus: "synced",
lastSyncedAt: new Date().toISOString(),
errorMessage: null,
retryCount: 0,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, meta.id))
result.pushed++
} catch (err) {
const message = err instanceof NetSuiteError
? err.message
: "Unknown error"
const retryable = err instanceof NetSuiteError && err.retryable
if (retryable && meta.retryCount < 3) {
await db
.update(netsuiteSyncMetadata)
.set({
retryCount: meta.retryCount + 1,
errorMessage: message,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, meta.id))
} else {
await markError(db, meta.id, message)
}
result.failed++
result.errors.push({
localId: meta.localRecordId,
error: message,
})
}
}
return result
}
async function markError(
db: DrizzleD1Database,
metaId: string,
message: string
): Promise<void> {
await db
.update(netsuiteSyncMetadata)
.set({
syncStatus: "error",
errorMessage: message,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, metaId))
}

View File

@ -0,0 +1,246 @@
import { eq, desc } from "drizzle-orm"
import type { DrizzleD1Database } from "drizzle-orm/d1"
import { netsuiteSyncLog, netsuiteSyncMetadata } from "@/db/schema-netsuite"
import { getNetSuiteConfig } from "../config"
import { TokenManager } from "../auth/token-manager"
import { BaseClient } from "../client/base-client"
import { RecordClient } from "../client/record-client"
import { SuiteQLClient } from "../client/suiteql-client"
import { ConcurrencyLimiter } from "../rate-limiter/concurrency-limiter"
import { pullDelta, type DeltaSyncResult } from "./delta-sync"
import { pushPendingChanges, type PushResult } from "./push"
import type { BaseMapper } from "../mappers/base-mapper"
import type { ConflictStrategy, SyncDirection } from "../client/types"
export interface SyncRunResult {
direction: SyncDirection
recordType: string
pull?: DeltaSyncResult
push?: PushResult
duration: number
}
export class SyncEngine {
private db: DrizzleD1Database
private recordClient: RecordClient
private suiteqlClient: SuiteQLClient
private conflictStrategy: ConflictStrategy
constructor(
db: DrizzleD1Database,
env: Record<string, string | undefined>,
conflictStrategy: ConflictStrategy = "newest_wins"
) {
this.db = db
this.conflictStrategy = conflictStrategy
const config = getNetSuiteConfig(
env as Record<string, string>
)
const tokenManager = new TokenManager(config, db as never)
const limiter = new ConcurrencyLimiter(config.concurrencyLimit)
const baseClient = new BaseClient(tokenManager, limiter)
this.recordClient = new RecordClient(
baseClient,
config.accountId
)
this.suiteqlClient = new SuiteQLClient(
baseClient,
config.accountId
)
}
async pull(
mapper: BaseMapper<
Record<string, unknown>,
Record<string, unknown>
>,
upsertLocal: (
localId: string | null,
data: Record<string, unknown>
) => Promise<string>
): Promise<SyncRunResult> {
const start = Date.now()
const recordType = mapper.getNetSuiteRecordType()
const logId = await this.startLog("delta", recordType, "pull")
try {
const lastSync = await this.getLastSyncTime(
mapper.getLocalTable(),
"pull"
)
const result = await pullDelta(
this.db,
this.suiteqlClient,
mapper,
lastSync,
this.conflictStrategy,
upsertLocal
)
await this.completeLog(logId, "completed", result.pulled, result.errors.length)
return {
direction: "pull",
recordType,
pull: result,
duration: Date.now() - start,
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown"
await this.completeLog(logId, "failed", 0, 0, message)
throw err
}
}
async push(
mapper: BaseMapper<
Record<string, unknown>,
Record<string, unknown>
>,
getLocalRecord: (
id: string
) => Promise<Record<string, unknown> | null>
): Promise<SyncRunResult> {
const start = Date.now()
const recordType = mapper.getNetSuiteRecordType()
const logId = await this.startLog("delta", recordType, "push")
try {
const result = await pushPendingChanges(
this.db,
this.recordClient,
mapper,
getLocalRecord
)
await this.completeLog(
logId,
"completed",
result.pushed,
result.failed
)
return {
direction: "push",
recordType,
push: result,
duration: Date.now() - start,
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown"
await this.completeLog(logId, "failed", 0, 0, message)
throw err
}
}
async fullSync(
mapper: BaseMapper<
Record<string, unknown>,
Record<string, unknown>
>,
upsertLocal: (
localId: string | null,
data: Record<string, unknown>
) => Promise<string>,
getLocalRecord: (
id: string
) => Promise<Record<string, unknown> | null>
): Promise<{ pull: SyncRunResult; push: SyncRunResult }> {
const pullResult = await this.pull(mapper, upsertLocal)
const pushResult = await this.push(mapper, getLocalRecord)
return { pull: pullResult, push: pushResult }
}
async getSyncHistory(
limit = 20
): Promise<typeof netsuiteSyncLog.$inferSelect[]> {
return this.db
.select()
.from(netsuiteSyncLog)
.orderBy(desc(netsuiteSyncLog.createdAt))
.limit(limit)
}
async getConflicts() {
return this.db
.select()
.from(netsuiteSyncMetadata)
.where(eq(netsuiteSyncMetadata.syncStatus, "conflict"))
}
async resolveConflict(
metaId: string,
resolution: "use_local" | "use_remote"
): Promise<void> {
const newStatus = resolution === "use_local"
? "pending_push"
: "synced"
await this.db
.update(netsuiteSyncMetadata)
.set({
syncStatus: newStatus,
conflictData: null,
updatedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncMetadata.id, metaId))
}
private async getLastSyncTime(
tableName: string,
direction: string
): Promise<string | null> {
const rows = await this.db
.select({ completedAt: netsuiteSyncLog.completedAt })
.from(netsuiteSyncLog)
.where(eq(netsuiteSyncLog.direction, direction))
.orderBy(desc(netsuiteSyncLog.completedAt))
.limit(1)
return rows[0]?.completedAt ?? null
}
private async startLog(
syncType: string,
recordType: string,
direction: string
): Promise<string> {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await this.db.insert(netsuiteSyncLog).values({
id,
syncType,
recordType,
direction,
status: "running",
recordsProcessed: 0,
recordsFailed: 0,
startedAt: now,
createdAt: now,
})
return id
}
private async completeLog(
logId: string,
status: string,
processed: number,
failed: number,
errorSummary?: string
): Promise<void> {
await this.db
.update(netsuiteSyncLog)
.set({
status,
recordsProcessed: processed,
recordsFailed: failed,
errorSummary: errorSummary ?? null,
completedAt: new Date().toISOString(),
})
.where(eq(netsuiteSyncLog.id, logId))
}
}