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:
parent
6a1afd7b49
commit
fbd31b58ae
120
src/app/actions/credit-memos.ts
Executable file
120
src/app/actions/credit-memos.ts
Executable 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
114
src/app/actions/customers.ts
Executable 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
117
src/app/actions/invoices.ts
Executable 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
238
src/app/actions/netsuite-sync.ts
Executable 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
114
src/app/actions/payments.ts
Executable 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
117
src/app/actions/vendor-bills.ts
Executable 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
114
src/app/actions/vendors.ts
Executable 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/app/api/netsuite/callback/route.ts
Executable file
82
src/app/api/netsuite/callback/route.ts
Executable 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
|
||||
}
|
||||
148
src/app/dashboard/customers/page.tsx
Executable file
148
src/app/dashboard/customers/page.tsx
Executable 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
441
src/app/dashboard/financials/page.tsx
Executable file
441
src/app/dashboard/financials/page.tsx
Executable 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
147
src/app/dashboard/vendors/page.tsx
vendored
Executable 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
220
src/components/financials/credit-memo-dialog.tsx
Executable file
220
src/components/financials/credit-memo-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
352
src/components/financials/credit-memos-table.tsx
Executable file
352
src/components/financials/credit-memos-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
169
src/components/financials/customer-dialog.tsx
Executable file
169
src/components/financials/customer-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
374
src/components/financials/customers-table.tsx
Executable file
374
src/components/financials/customers-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
279
src/components/financials/invoice-dialog.tsx
Executable file
279
src/components/financials/invoice-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
368
src/components/financials/invoices-table.tsx
Executable file
368
src/components/financials/invoices-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
198
src/components/financials/line-items-editor.tsx
Executable file
198
src/components/financials/line-items-editor.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
268
src/components/financials/payment-dialog.tsx
Executable file
268
src/components/financials/payment-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
374
src/components/financials/payments-table.tsx
Executable file
374
src/components/financials/payments-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
276
src/components/financials/vendor-bill-dialog.tsx
Executable file
276
src/components/financials/vendor-bill-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
365
src/components/financials/vendor-bills-table.tsx
Executable file
365
src/components/financials/vendor-bills-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
174
src/components/financials/vendor-dialog.tsx
Executable file
174
src/components/financials/vendor-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
420
src/components/financials/vendors-table.tsx
Executable file
420
src/components/financials/vendors-table.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
128
src/components/netsuite/conflict-dialog.tsx
Executable file
128
src/components/netsuite/conflict-dialog.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
125
src/components/netsuite/connection-status.tsx
Executable file
125
src/components/netsuite/connection-status.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
68
src/components/netsuite/sync-controls.tsx
Executable file
68
src/components/netsuite/sync-controls.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
19
src/components/netsuite/sync-status-badge.tsx
Executable file
19
src/components/netsuite/sync-status-badge.tsx
Executable 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
76
src/lib/netsuite/auth/crypto.ts
Executable 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)
|
||||
}
|
||||
120
src/lib/netsuite/auth/oauth-client.ts
Executable file
120
src/lib/netsuite/auth/oauth-client.ts
Executable 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}`)}`
|
||||
}
|
||||
148
src/lib/netsuite/auth/token-manager.ts
Executable file
148
src/lib/netsuite/auth/token-manager.ts
Executable 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
|
||||
}
|
||||
}
|
||||
146
src/lib/netsuite/client/base-client.ts
Executable file
146
src/lib/netsuite/client/base-client.ts
Executable 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
169
src/lib/netsuite/client/errors.ts
Executable 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"
|
||||
}
|
||||
137
src/lib/netsuite/client/record-client.ts
Executable file
137
src/lib/netsuite/client/record-client.ts
Executable 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
|
||||
}
|
||||
}
|
||||
69
src/lib/netsuite/client/suiteql-client.ts
Executable file
69
src/lib/netsuite/client/suiteql-client.ts
Executable 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
138
src/lib/netsuite/client/types.ts
Executable 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
66
src/lib/netsuite/config.ts
Executable 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
|
||||
}
|
||||
57
src/lib/netsuite/mappers/base-mapper.ts
Executable file
57
src/lib/netsuite/mappers/base-mapper.ts
Executable 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}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
49
src/lib/netsuite/mappers/customer-mapper.ts
Executable file
49
src/lib/netsuite/mappers/customer-mapper.ts
Executable 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
78
src/lib/netsuite/mappers/invoice-mapper.ts
Executable file
78
src/lib/netsuite/mappers/invoice-mapper.ts
Executable 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"
|
||||
}
|
||||
53
src/lib/netsuite/mappers/project-mapper.ts
Executable file
53
src/lib/netsuite/mappers/project-mapper.ts
Executable 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"
|
||||
}
|
||||
78
src/lib/netsuite/mappers/vendor-bill-mapper.ts
Executable file
78
src/lib/netsuite/mappers/vendor-bill-mapper.ts
Executable 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"
|
||||
}
|
||||
52
src/lib/netsuite/mappers/vendor-mapper.ts
Executable file
52
src/lib/netsuite/mappers/vendor-mapper.ts
Executable 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
82
src/lib/netsuite/rate-limiter/concurrency-limiter.ts
Executable file
82
src/lib/netsuite/rate-limiter/concurrency-limiter.ts
Executable 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
|
||||
}
|
||||
}
|
||||
37
src/lib/netsuite/rate-limiter/request-queue.ts
Executable file
37
src/lib/netsuite/rate-limiter/request-queue.ts
Executable 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/lib/netsuite/sync/conflict-resolver.ts
Executable file
67
src/lib/netsuite/sync/conflict-resolver.ts
Executable 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})`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/lib/netsuite/sync/delta-sync.ts
Executable file
143
src/lib/netsuite/sync/delta-sync.ts
Executable 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
|
||||
}
|
||||
15
src/lib/netsuite/sync/idempotency.ts
Executable file
15
src/lib/netsuite/sync/idempotency.ts
Executable 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
128
src/lib/netsuite/sync/push.ts
Executable 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))
|
||||
}
|
||||
246
src/lib/netsuite/sync/sync-engine.ts
Executable file
246
src/lib/netsuite/sync/sync-engine.ts
Executable 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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user