From fbd31b58aef120c32a649d37310a3140fa1294ba Mon Sep 17 00:00:00 2001 From: Nicholai Date: Wed, 4 Feb 2026 16:36:19 -0700 Subject: [PATCH] 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 --- src/app/actions/credit-memos.ts | 120 +++++ src/app/actions/customers.ts | 114 +++++ src/app/actions/invoices.ts | 117 +++++ src/app/actions/netsuite-sync.ts | 238 ++++++++++ src/app/actions/payments.ts | 114 +++++ src/app/actions/vendor-bills.ts | 117 +++++ src/app/actions/vendors.ts | 114 +++++ src/app/api/netsuite/callback/route.ts | 82 ++++ src/app/dashboard/customers/page.tsx | 148 ++++++ src/app/dashboard/financials/page.tsx | 441 ++++++++++++++++++ src/app/dashboard/vendors/page.tsx | 147 ++++++ .../financials/credit-memo-dialog.tsx | 220 +++++++++ .../financials/credit-memos-table.tsx | 352 ++++++++++++++ src/components/financials/customer-dialog.tsx | 169 +++++++ src/components/financials/customers-table.tsx | 374 +++++++++++++++ src/components/financials/invoice-dialog.tsx | 279 +++++++++++ src/components/financials/invoices-table.tsx | 368 +++++++++++++++ .../financials/line-items-editor.tsx | 198 ++++++++ src/components/financials/payment-dialog.tsx | 268 +++++++++++ src/components/financials/payments-table.tsx | 374 +++++++++++++++ .../financials/vendor-bill-dialog.tsx | 276 +++++++++++ .../financials/vendor-bills-table.tsx | 365 +++++++++++++++ src/components/financials/vendor-dialog.tsx | 174 +++++++ src/components/financials/vendors-table.tsx | 420 +++++++++++++++++ src/components/netsuite/conflict-dialog.tsx | 128 +++++ src/components/netsuite/connection-status.tsx | 125 +++++ src/components/netsuite/sync-controls.tsx | 68 +++ src/components/netsuite/sync-status-badge.tsx | 19 + src/lib/netsuite/auth/crypto.ts | 76 +++ src/lib/netsuite/auth/oauth-client.ts | 120 +++++ src/lib/netsuite/auth/token-manager.ts | 148 ++++++ src/lib/netsuite/client/base-client.ts | 146 ++++++ src/lib/netsuite/client/errors.ts | 169 +++++++ src/lib/netsuite/client/record-client.ts | 137 ++++++ src/lib/netsuite/client/suiteql-client.ts | 69 +++ src/lib/netsuite/client/types.ts | 138 ++++++ src/lib/netsuite/config.ts | 66 +++ src/lib/netsuite/mappers/base-mapper.ts | 57 +++ src/lib/netsuite/mappers/customer-mapper.ts | 49 ++ src/lib/netsuite/mappers/invoice-mapper.ts | 78 ++++ src/lib/netsuite/mappers/project-mapper.ts | 53 +++ .../netsuite/mappers/vendor-bill-mapper.ts | 78 ++++ src/lib/netsuite/mappers/vendor-mapper.ts | 52 +++ .../rate-limiter/concurrency-limiter.ts | 82 ++++ .../netsuite/rate-limiter/request-queue.ts | 37 ++ src/lib/netsuite/sync/conflict-resolver.ts | 67 +++ src/lib/netsuite/sync/delta-sync.ts | 143 ++++++ src/lib/netsuite/sync/idempotency.ts | 15 + src/lib/netsuite/sync/push.ts | 128 +++++ src/lib/netsuite/sync/sync-engine.ts | 246 ++++++++++ 50 files changed, 8083 insertions(+) create mode 100755 src/app/actions/credit-memos.ts create mode 100755 src/app/actions/customers.ts create mode 100755 src/app/actions/invoices.ts create mode 100755 src/app/actions/netsuite-sync.ts create mode 100755 src/app/actions/payments.ts create mode 100755 src/app/actions/vendor-bills.ts create mode 100755 src/app/actions/vendors.ts create mode 100755 src/app/api/netsuite/callback/route.ts create mode 100755 src/app/dashboard/customers/page.tsx create mode 100755 src/app/dashboard/financials/page.tsx create mode 100755 src/app/dashboard/vendors/page.tsx create mode 100755 src/components/financials/credit-memo-dialog.tsx create mode 100755 src/components/financials/credit-memos-table.tsx create mode 100755 src/components/financials/customer-dialog.tsx create mode 100755 src/components/financials/customers-table.tsx create mode 100755 src/components/financials/invoice-dialog.tsx create mode 100755 src/components/financials/invoices-table.tsx create mode 100755 src/components/financials/line-items-editor.tsx create mode 100755 src/components/financials/payment-dialog.tsx create mode 100755 src/components/financials/payments-table.tsx create mode 100755 src/components/financials/vendor-bill-dialog.tsx create mode 100755 src/components/financials/vendor-bills-table.tsx create mode 100755 src/components/financials/vendor-dialog.tsx create mode 100755 src/components/financials/vendors-table.tsx create mode 100755 src/components/netsuite/conflict-dialog.tsx create mode 100755 src/components/netsuite/connection-status.tsx create mode 100755 src/components/netsuite/sync-controls.tsx create mode 100755 src/components/netsuite/sync-status-badge.tsx create mode 100755 src/lib/netsuite/auth/crypto.ts create mode 100755 src/lib/netsuite/auth/oauth-client.ts create mode 100755 src/lib/netsuite/auth/token-manager.ts create mode 100755 src/lib/netsuite/client/base-client.ts create mode 100755 src/lib/netsuite/client/errors.ts create mode 100755 src/lib/netsuite/client/record-client.ts create mode 100755 src/lib/netsuite/client/suiteql-client.ts create mode 100755 src/lib/netsuite/client/types.ts create mode 100755 src/lib/netsuite/config.ts create mode 100755 src/lib/netsuite/mappers/base-mapper.ts create mode 100755 src/lib/netsuite/mappers/customer-mapper.ts create mode 100755 src/lib/netsuite/mappers/invoice-mapper.ts create mode 100755 src/lib/netsuite/mappers/project-mapper.ts create mode 100755 src/lib/netsuite/mappers/vendor-bill-mapper.ts create mode 100755 src/lib/netsuite/mappers/vendor-mapper.ts create mode 100755 src/lib/netsuite/rate-limiter/concurrency-limiter.ts create mode 100755 src/lib/netsuite/rate-limiter/request-queue.ts create mode 100755 src/lib/netsuite/sync/conflict-resolver.ts create mode 100755 src/lib/netsuite/sync/delta-sync.ts create mode 100755 src/lib/netsuite/sync/idempotency.ts create mode 100755 src/lib/netsuite/sync/push.ts create mode 100755 src/lib/netsuite/sync/sync-engine.ts diff --git a/src/app/actions/credit-memos.ts b/src/app/actions/credit-memos.ts new file mode 100755 index 0000000..06c9a68 --- /dev/null +++ b/src/app/actions/credit-memos.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/actions/customers.ts b/src/app/actions/customers.ts new file mode 100755 index 0000000..299ca74 --- /dev/null +++ b/src/app/actions/customers.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/actions/invoices.ts b/src/app/actions/invoices.ts new file mode 100755 index 0000000..d87a34d --- /dev/null +++ b/src/app/actions/invoices.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/actions/netsuite-sync.ts b/src/app/actions/netsuite-sync.ts new file mode 100755 index 0000000..98a449a --- /dev/null +++ b/src/app/actions/netsuite-sync.ts @@ -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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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", + } + } +} diff --git a/src/app/actions/payments.ts b/src/app/actions/payments.ts new file mode 100755 index 0000000..2e5fd98 --- /dev/null +++ b/src/app/actions/payments.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/actions/vendor-bills.ts b/src/app/actions/vendor-bills.ts new file mode 100755 index 0000000..e7d3512 --- /dev/null +++ b/src/app/actions/vendor-bills.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/actions/vendors.ts b/src/app/actions/vendors.ts new file mode 100755 index 0000000..5db2346 --- /dev/null +++ b/src/app/actions/vendors.ts @@ -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 +) { + 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 +) { + 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", + } + } +} diff --git a/src/app/api/netsuite/callback/route.ts b/src/app/api/netsuite/callback/route.ts new file mode 100755 index 0000000..a833bec --- /dev/null +++ b/src/app/api/netsuite/callback/route.ts @@ -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 + 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 { + const cookies: Record = {} + for (const pair of header.split(";")) { + const [key, ...rest] = pair.trim().split("=") + if (key) cookies[key] = rest.join("=") + } + return cookies +} diff --git a/src/app/dashboard/customers/page.tsx b/src/app/dashboard/customers/page.tsx new file mode 100755 index 0000000..65df670 --- /dev/null +++ b/src/app/dashboard/customers/page.tsx @@ -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([]) + const [loading, setLoading] = React.useState(true) + const [dialogOpen, setDialogOpen] = React.useState(false) + const [editing, setEditing] = React.useState(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 ( +
+
+
+

+ Customers +

+

+ Manage customer accounts +

+
+
+
+ Loading... +
+
+ ) + } + + return ( + <> +
+
+
+

+ Customers +

+

+ Manage customer accounts +

+
+ +
+ + {customers.length === 0 ? ( +
+

No customers yet

+

+ Add your first customer to start tracking contacts and invoices. +

+
+ ) : ( + + )} +
+ + + + ) +} diff --git a/src/app/dashboard/financials/page.tsx b/src/app/dashboard/financials/page.tsx new file mode 100755 index 0000000..212eac3 --- /dev/null +++ b/src/app/dashboard/financials/page.tsx @@ -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 ( + +
+

+ Financials +

+

+ Invoices, bills, payments, and credit memos +

+
+
+ Loading... +
+ + }> + +
+ ) +} + +function FinancialsContent() { + const searchParams = useSearchParams() + const router = useRouter() + const initialTab = (searchParams.get("tab") as Tab) || "invoices" + + const [tab, setTab] = React.useState(initialTab) + const [loading, setLoading] = React.useState(true) + + const [customersList, setCustomersList] = React.useState([]) + const [vendorsList, setVendorsList] = React.useState([]) + const [projectsList, setProjectsList] = React.useState([]) + + const [invoicesList, setInvoicesList] = React.useState([]) + const [billsList, setBillsList] = React.useState([]) + const [paymentsList, setPaymentsList] = React.useState([]) + const [memosList, setMemosList] = React.useState([]) + + const [invoiceDialogOpen, setInvoiceDialogOpen] = React.useState(false) + const [editingInvoice, setEditingInvoice] = + React.useState(null) + + const [billDialogOpen, setBillDialogOpen] = React.useState(false) + const [editingBill, setEditingBill] = + React.useState(null) + + const [paymentDialogOpen, setPaymentDialogOpen] = React.useState(false) + const [editingPayment, setEditingPayment] = + React.useState(null) + + const [memoDialogOpen, setMemoDialogOpen] = React.useState(false) + const [editingMemo, setEditingMemo] = + React.useState(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[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[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[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[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 ( +
+
+

+ Financials +

+

+ Invoices, bills, payments, and credit memos +

+
+
+ Loading... +
+
+ ) + } + + return ( + <> +
+
+

+ Financials +

+

+ Invoices, bills, payments, and credit memos +

+
+ + +
+
+ + + Invoices + + + Bills + + + Payments + + + Credits + Credit Memos + + +
+ + {tab === "invoices" && ( + + )} + {tab === "bills" && ( + + )} + {tab === "payments" && ( + + )} + {tab === "credit-memos" && ( + + )} +
+ + + { + setEditingInvoice(inv) + setInvoiceDialogOpen(true) + }} + onDelete={handleDeleteInvoice} + /> + + + + { + setEditingBill(bill) + setBillDialogOpen(true) + }} + onDelete={handleDeleteBill} + /> + + + + { + setEditingPayment(pay) + setPaymentDialogOpen(true) + }} + onDelete={handleDeletePayment} + /> + + + + { + setEditingMemo(memo) + setMemoDialogOpen(true) + }} + onDelete={handleDeleteMemo} + /> + +
+
+ + + + + + + + + + ) +} diff --git a/src/app/dashboard/vendors/page.tsx b/src/app/dashboard/vendors/page.tsx new file mode 100755 index 0000000..7717c38 --- /dev/null +++ b/src/app/dashboard/vendors/page.tsx @@ -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([]) + const [loading, setLoading] = React.useState(true) + const [dialogOpen, setDialogOpen] = React.useState(false) + const [editing, setEditing] = React.useState(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 ( +
+
+
+

+ Vendors +

+

+ Manage vendor relationships +

+
+
+
+ Loading... +
+
+ ) + } + + return ( + <> +
+
+
+

+ Vendors +

+

+ Manage vendor relationships +

+
+ +
+ + {vendors.length === 0 ? ( +
+

No vendors yet

+

+ Add your first vendor to manage subcontractors, suppliers, and bills. +

+
+ ) : ( + + )} +
+ + + + ) +} diff --git a/src/components/financials/credit-memo-dialog.tsx b/src/components/financials/credit-memo-dialog.tsx new file mode 100755 index 0000000..400030a --- /dev/null +++ b/src/components/financials/credit-memo-dialog.tsx @@ -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([]) + + 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 = ( + <> +
+ + +
+
+ + +
+
+ + setMemoNumber(e.target.value)} + /> +
+
+ + +
+ + ) + + const page2 = ( + <> +
+ + +
+
+ +