diff --git a/apps/web/app/api/finance/customer-invoices/[id]/route.ts b/apps/web/app/api/finance/customer-invoices/[id]/route.ts new file mode 100644 index 00000000..5dec453d --- /dev/null +++ b/apps/web/app/api/finance/customer-invoices/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCustomerInvoices, getCustomerInvoiceById, createCustomerInvoice, updateCustomerInvoice, deleteCustomerInvoice } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/customer-invoices/[id] + * Get customer invoice by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const invoice = await getCustomerInvoiceById(id) + + if (!invoice) { + return NextResponse.json({ error: 'Customer invoice not found' }, { status: 404 }) + } + + return NextResponse.json({ data: invoice }) + } catch (error) { + console.error('Error fetching customer invoice:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch customer invoice' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/customer-invoices/[id] + * Update customer invoice + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const invoice = await updateCustomerInvoice(id, body) + + return NextResponse.json({ data: invoice }) + } catch (error) { + console.error('Error updating customer invoice:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update customer invoice' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/customer-invoices/[id] + * Delete customer invoice + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deleteCustomerInvoice(id) + return NextResponse.json({ message: 'Customer invoice deleted successfully' }) + } catch (error) { + console.error('Error deleting customer invoice:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete customer invoice' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/customer-invoices/route.ts b/apps/web/app/api/finance/customer-invoices/route.ts new file mode 100644 index 00000000..61e10c5d --- /dev/null +++ b/apps/web/app/api/finance/customer-invoices/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getCustomerInvoices, + getCustomerInvoiceById, + createCustomerInvoice, + updateCustomerInvoice, + deleteCustomerInvoice +} from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/customer-invoices + * Get all customer invoices (Accounts Receivable) + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + const status = searchParams.get('status') || undefined + + const invoices = await getCustomerInvoices(entityId, status) + return NextResponse.json({ data: invoices }) + } catch (error) { + console.error('Error fetching customer invoices:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch customer invoices' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/customer-invoices + * Create new customer invoice with line items + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { lines = [], ...invoiceData } = body + + const invoice = await createCustomerInvoice( + invoiceData, + lines.map((line: any, index: number) => ({ + ...line, + line_number: index + 1, + })) + ) + + return NextResponse.json({ data: invoice }, { status: 201 }) + } catch (error) { + console.error('Error creating customer invoice:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create customer invoice' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/customers/[id]/route.ts b/apps/web/app/api/finance/customers/[id]/route.ts new file mode 100644 index 00000000..ac82c43b --- /dev/null +++ b/apps/web/app/api/finance/customers/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCustomerById, updateCustomer, deleteCustomer } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/customers/[id] + * Get customer by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const customer = await getCustomerById(id) + + if (!customer) { + return NextResponse.json({ error: 'Customer not found' }, { status: 404 }) + } + + return NextResponse.json({ data: customer }) + } catch (error) { + console.error('Error fetching customer:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch customer' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/customers/[id] + * Update customer + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const customer = await updateCustomer(id, body) + + return NextResponse.json({ data: customer }) + } catch (error) { + console.error('Error updating customer:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update customer' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/customers/[id] + * Delete customer + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deleteCustomer(id) + return NextResponse.json({ message: 'Customer deleted successfully' }) + } catch (error) { + console.error('Error deleting customer:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete customer' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/customers/route.ts b/apps/web/app/api/finance/customers/route.ts new file mode 100644 index 00000000..b3f7bf2a --- /dev/null +++ b/apps/web/app/api/finance/customers/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getCustomers, createCustomer, updateCustomer, deleteCustomer } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/customers + * Get all customers for current tenant + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + + const customers = await getCustomers(entityId) + return NextResponse.json({ data: customers }) + } catch (error) { + console.error('Error fetching customers:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch customers' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/customers + * Create new customer + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const customer = await createCustomer(body) + + return NextResponse.json({ data: customer }, { status: 201 }) + } catch (error) { + console.error('Error creating customer:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create customer' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/journal/route.ts b/apps/web/app/api/finance/journal/route.ts index b3d58c5d..7b9fcc91 100644 --- a/apps/web/app/api/finance/journal/route.ts +++ b/apps/web/app/api/finance/journal/route.ts @@ -102,12 +102,34 @@ export async function POST(request: NextRequest) { ) } + const { lines, ...entryData } = body; + + // Auto-generate entry number if not provided + let entryNumber = entryData.entry_number; + if (!entryNumber) { + const date = new Date(entryData.transaction_date || new Date()); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const ts = Date.now().toString().slice(-5); + entryNumber = `JE-${year}${month}-${ts}`; + } + + const linesWithTenant = lines.map((line: any) => ({ + ...line, + tenant_id: tenantId, + created_by: '812558af-8be8-4c53-b581-e6a4f1c91147', + updated_by: '812558af-8be8-4c53-b581-e6a4f1c91147' + })); + const entry = await createJournalEntry( { - ...body, - prepared_by: '00000000-0000-0000-0000-000000000000' // System user + ...entryData, + entry_number: entryNumber, + prepared_by: '812558af-8be8-4c53-b581-e6a4f1c91147', + created_by: '812558af-8be8-4c53-b581-e6a4f1c91147', + tenant_id: tenantId }, - body.lines + linesWithTenant ) return NextResponse.json(entry, { status: 201 }) } catch (error) { diff --git a/apps/web/app/api/finance/payments/[id]/route.ts b/apps/web/app/api/finance/payments/[id]/route.ts new file mode 100644 index 00000000..72806b76 --- /dev/null +++ b/apps/web/app/api/finance/payments/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPaymentById, updatePayment, deletePayment } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/payments/[id] + * Get payment by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const payment = await getPaymentById(id) + + if (!payment) { + return NextResponse.json({ error: 'Payment not found' }, { status: 404 }) + } + + return NextResponse.json({ data: payment }) + } catch (error) { + console.error('Error fetching payment:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch payment' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/payments/[id] + * Update payment + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const payment = await updatePayment(id, body) + + return NextResponse.json({ data: payment }) + } catch (error) { + console.error('Error updating payment:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update payment' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/payments/[id] + * Delete payment + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deletePayment(id) + return NextResponse.json({ message: 'Payment deleted successfully' }) + } catch (error) { + console.error('Error deleting payment:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete payment' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/payments/route.ts b/apps/web/app/api/finance/payments/route.ts new file mode 100644 index 00000000..51d03707 --- /dev/null +++ b/apps/web/app/api/finance/payments/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getPayments, getPaymentById, createPayment, updatePayment, deletePayment } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/payments + * Get all payments + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + const status = searchParams.get('status') || undefined + + const payments = await getPayments(entityId, status) + return NextResponse.json({ data: payments }) + } catch (error) { + console.error('Error fetching payments:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch payments' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/payments + * Create new payment + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const payment = await createPayment(body) + + return NextResponse.json({ data: payment }, { status: 201 }) + } catch (error) { + console.error('Error creating payment:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create payment' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/receipts/[id]/route.ts b/apps/web/app/api/finance/receipts/[id]/route.ts new file mode 100644 index 00000000..3452436a --- /dev/null +++ b/apps/web/app/api/finance/receipts/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getReceiptById, updateReceipt, deleteReceipt } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/receipts/[id] + * Get receipt by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const receipt = await getReceiptById(id) + + if (!receipt) { + return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }) + } + + return NextResponse.json({ data: receipt }) + } catch (error) { + console.error('Error fetching receipt:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch receipt' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/receipts/[id] + * Update receipt + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const receipt = await updateReceipt(id, body) + + return NextResponse.json({ data: receipt }) + } catch (error) { + console.error('Error updating receipt:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update receipt' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/receipts/[id] + * Delete receipt + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deleteReceipt(id) + return NextResponse.json({ message: 'Receipt deleted successfully' }) + } catch (error) { + console.error('Error deleting receipt:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete receipt' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/receipts/route.ts b/apps/web/app/api/finance/receipts/route.ts new file mode 100644 index 00000000..6cf81ce4 --- /dev/null +++ b/apps/web/app/api/finance/receipts/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getReceipts, getReceiptById, createReceipt, updateReceipt, deleteReceipt } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/receipts + * Get all receipts + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + const status = searchParams.get('status') || undefined + + const receipts = await getReceipts(entityId, status) + return NextResponse.json({ data: receipts }) + } catch (error) { + console.error('Error fetching receipts:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch receipts' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/receipts + * Create new receipt + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const receipt = await createReceipt(body) + + return NextResponse.json({ data: receipt }, { status: 201 }) + } catch (error) { + console.error('Error creating receipt:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create receipt' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/vendor-bills/[id]/route.ts b/apps/web/app/api/finance/vendor-bills/[id]/route.ts new file mode 100644 index 00000000..e8597923 --- /dev/null +++ b/apps/web/app/api/finance/vendor-bills/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getVendorBillById, updateVendorBill, deleteVendorBill } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/vendor-bills/[id] + * Get vendor bill by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const bill = await getVendorBillById(id) + + if (!bill) { + return NextResponse.json({ error: 'Vendor bill not found' }, { status: 404 }) + } + + return NextResponse.json({ data: bill }) + } catch (error) { + console.error('Error fetching vendor bill:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch vendor bill' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/vendor-bills/[id] + * Update vendor bill + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const bill = await updateVendorBill(id, body) + + return NextResponse.json({ data: bill }) + } catch (error) { + console.error('Error updating vendor bill:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update vendor bill' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/vendor-bills/[id] + * Delete vendor bill + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deleteVendorBill(id) + return NextResponse.json({ message: 'Vendor bill deleted successfully' }) + } catch (error) { + console.error('Error deleting vendor bill:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete vendor bill' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/vendor-bills/route.ts b/apps/web/app/api/finance/vendor-bills/route.ts new file mode 100644 index 00000000..8c996ae9 --- /dev/null +++ b/apps/web/app/api/finance/vendor-bills/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getVendorBills, + getVendorBillById, + createVendorBill, + updateVendorBill, + deleteVendorBill +} from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/vendor-bills + * Get all vendor bills (Accounts Payable) + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + const status = searchParams.get('status') || undefined + + const bills = await getVendorBills(entityId, status) + return NextResponse.json({ data: bills }) + } catch (error) { + console.error('Error fetching vendor bills:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch vendor bills' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/vendor-bills + * Create new vendor bill with line items + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { lines = [], ...billData } = body + + const bill = await createVendorBill( + billData, + lines.map((line: any, index: number) => ({ + ...line, + line_number: index + 1, + })) + ) + + return NextResponse.json({ data: bill }, { status: 201 }) + } catch (error) { + console.error('Error creating vendor bill:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create vendor bill' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/vendors/[id]/route.ts b/apps/web/app/api/finance/vendors/[id]/route.ts new file mode 100644 index 00000000..238abbf9 --- /dev/null +++ b/apps/web/app/api/finance/vendors/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getVendorById, updateVendor, deleteVendor } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/vendors/[id] + * Get vendor by ID + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const vendor = await getVendorById(id) + + if (!vendor) { + return NextResponse.json({ error: 'Vendor not found' }, { status: 404 }) + } + + return NextResponse.json({ data: vendor }) + } catch (error) { + console.error('Error fetching vendor:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch vendor' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/finance/vendors/[id] + * Update vendor + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const vendor = await updateVendor(id, body) + + return NextResponse.json({ data: vendor }) + } catch (error) { + console.error('Error updating vendor:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update vendor' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/finance/vendors/[id] + * Soft delete vendor + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await deleteVendor(id) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting vendor:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete vendor' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/finance/vendors/route.ts b/apps/web/app/api/finance/vendors/route.ts new file mode 100644 index 00000000..dda17a0c --- /dev/null +++ b/apps/web/app/api/finance/vendors/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getVendors, createVendor, updateVendor, deleteVendor } from '@/lib/repositories/finance-transactions' + +/** + * GET /api/finance/vendors + * Get all vendors for current tenant + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const entityId = searchParams.get('entity_id') || undefined + + const vendors = await getVendors(entityId) + return NextResponse.json({ data: vendors }) + } catch (error) { + console.error('Error fetching vendors:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch vendors' }, + { status: 500 } + ) + } +} + +/** + * POST /api/finance/vendors + * Create new vendor + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const vendor = await createVendor(body) + + return NextResponse.json({ data: vendor }, { status: 201 }) + } catch (error) { + console.error('Error creating vendor:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create vendor' }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/hc/employee-contracts/[id]/route.ts b/apps/web/app/api/hc/employee-contracts/[id]/route.ts new file mode 100644 index 00000000..1e6f5d40 --- /dev/null +++ b/apps/web/app/api/hc/employee-contracts/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getContractById, + updateContract, + deactivateContract, +} from '@/lib/repositories/hr-employee-contracts' + +/** + * GET /api/hc/employee-contracts/[id] + * Get single contract + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const contract = await getContractById(id) + return NextResponse.json({ data: contract }) + } catch (error) { + console.error('Error fetching contract:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/hc/employee-contracts/[id] + * Update contract + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + + const updates: Record = {} + const allowedFields = [ + 'contract_no', 'contract_type', 'start_date', 'end_date', + 'probation_end_date', 'position_id', 'department_id', 'grade_id', + 'base_salary', 'work_shift_id', 'work_area_id', 'is_active', + 'termination_reason', 'termination_date', 'document_url', 'signed_at', + ] + + for (const field of allowedFields) { + if (body[field] !== undefined) { + updates[field] = body[field] + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + } + + const contract = await updateContract(id, updates) + return NextResponse.json({ message: 'Contract updated', data: contract }) + } catch (error) { + console.error('Error updating contract:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/hc/employee-contracts/[id] + * Deactivate contract (soft delete) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const contract = await deactivateContract(id) + return NextResponse.json({ message: 'Contract deactivated', data: contract }) + } catch (error) { + console.error('Error deactivating contract:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/hc/employee-contracts/route.ts b/apps/web/app/api/hc/employee-contracts/route.ts new file mode 100644 index 00000000..8b58c36e --- /dev/null +++ b/apps/web/app/api/hc/employee-contracts/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getEmployeeContracts, + createContract, + generateContractNo, +} from '@/lib/repositories/hr-employee-contracts' +import { createServerClient } from '@/lib/supabase-server' + +/** + * GET /api/hc/employee-contracts?employee_id=xxx + * Get all contracts for an employee + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const employeeId = searchParams.get('employee_id') + + if (!employeeId) { + return NextResponse.json( + { error: 'employee_id query param is required' }, + { status: 400 } + ) + } + + const contracts = await getEmployeeContracts(employeeId) + return NextResponse.json({ data: contracts }) + } catch (error) { + console.error('Error fetching contracts:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * POST /api/hc/employee-contracts + * Create new contract (auto-generates contract_no) + */ +export async function POST(request: NextRequest) { + try { + const supabase = await createServerClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Get user's tenant + const { data: profile } = await supabase + .from('user_profiles') + .select('tenant_id') + .eq('user_id', user.id) + .single() + + if (!profile?.tenant_id) { + return NextResponse.json({ error: 'Tenant not found' }, { status: 403 }) + } + + const body = await request.json() + + // Validate required fields + if (!body.employee_id || !body.contract_type || !body.start_date || !body.position_id) { + return NextResponse.json( + { error: 'Missing required fields: employee_id, contract_type, start_date, position_id' }, + { status: 400 } + ) + } + + // Auto-generate contract number + const contractNo = await generateContractNo(profile.tenant_id, body.contract_type) + + const contract = await createContract({ + tenant_id: profile.tenant_id, + employee_id: body.employee_id, + contract_no: body.contract_no || contractNo, + contract_type: body.contract_type, + start_date: body.start_date, + end_date: body.end_date || null, + probation_end_date: body.probation_end_date || null, + position_id: body.position_id, + department_id: body.department_id || null, + grade_id: body.grade_id || null, + base_salary: body.base_salary ? Number(body.base_salary) : null, + work_shift_id: body.work_shift_id || null, + work_area_id: body.work_area_id || null, + is_active: body.is_active ?? true, + termination_reason: null, + termination_date: null, + document_url: null, + signed_at: null, + } as any) + + return NextResponse.json( + { message: 'Contract created', data: contract }, + { status: 201 } + ) + } catch (error) { + console.error('Error creating contract:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/hc/employee-documents/[id]/route.ts b/apps/web/app/api/hc/employee-documents/[id]/route.ts new file mode 100644 index 00000000..30110843 --- /dev/null +++ b/apps/web/app/api/hc/employee-documents/[id]/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getDocumentById, + updateDocument, + deleteDocument, + verifyDocument, +} from '@/lib/repositories/hr-employee-documents' + +/** + * GET /api/hc/employee-documents/[id] + * Get single document + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const document = await getDocumentById(id) + return NextResponse.json({ data: document }) + } catch (error) { + console.error('Error fetching document:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/hc/employee-documents/[id] + * Update document metadata, or verify it + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + + if (body.action === 'verify') { + const { data: { user } } = await (await import('@/lib/supabase-server')).createServerClient().auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const document = await verifyDocument(id, user.id) + return NextResponse.json({ message: 'Document verified', data: document }) + } + + const updates: Record = {} + const allowedFields = [ + 'document_type', 'document_name', 'notes', + ] + + for (const field of allowedFields) { + if (body[field] !== undefined) { + updates[field] = body[field] + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + } + + const document = await updateDocument(id, updates) + return NextResponse.json({ message: 'Document updated', data: document }) + } catch (error) { + console.error('Error updating document:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/hc/employee-documents/[id] + * Delete document (including file from storage) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get document first to get file path + const document = await getDocumentById(id) + + // Delete from storage if URL exists + if (document?.file_url) { + try { + const supabase = (await import('@/lib/supabase-server')).createServerClient() + const url = new URL(document.file_url) + const pathMatch = url.pathname.match(/employee-documents\/(.*)/) + if (pathMatch) { + await supabase.storage.from('employee-documents').remove([pathMatch[1]]) + } + } catch (storageError) { + console.warn('Failed to delete file from storage:', storageError) + } + } + + await deleteDocument(id) + return NextResponse.json({ message: 'Document deleted' }) + } catch (error) { + console.error('Error deleting document:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/hc/employee-documents/route.ts b/apps/web/app/api/hc/employee-documents/route.ts new file mode 100644 index 00000000..4d219722 --- /dev/null +++ b/apps/web/app/api/hc/employee-documents/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getEmployeeDocuments, + createDocument, + getDocumentUploadUrl, +} from '@/lib/repositories/hr-employee-documents' +import { createServerClient } from '@/lib/supabase-server' + +/** + * GET /api/hc/employee-documents?employee_id=xxx + * Get all documents for an employee + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const employeeId = searchParams.get('employee_id') + + if (!employeeId) { + return NextResponse.json( + { error: 'employee_id query param is required' }, + { status: 400 } + ) + } + + const documents = await getEmployeeDocuments(employeeId) + return NextResponse.json({ data: documents }) + } catch (error) { + console.error('Error fetching documents:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} + +/** + * POST /api/hc/employee-documents + * Create document record (after file upload to storage) + */ +export async function POST(request: NextRequest) { + try { + const supabase = await createServerClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data: profile } = await supabase + .from('user_profiles') + .select('tenant_id') + .eq('user_id', user.id) + .single() + + if (!profile?.tenant_id) { + return NextResponse.json({ error: 'Tenant not found' }, { status: 403 }) + } + + const body = await request.json() + + if (!body.employee_id || !body.document_type || !body.document_name || !body.file_url) { + return NextResponse.json( + { error: 'Missing required fields: employee_id, document_type, document_name, file_url' }, + { status: 400 } + ) + } + + const document = await createDocument({ + tenant_id: profile.tenant_id, + employee_id: body.employee_id, + document_type: body.document_type, + document_name: body.document_name, + file_url: body.file_url, + file_size: body.file_size || null, + mime_type: body.mime_type || null, + is_verified: false, + notes: body.notes || null, + }) + + return NextResponse.json( + { message: 'Document recorded', data: document }, + { status: 201 } + ) + } catch (error) { + console.error('Error creating document:', error) + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/web/app/api/users/[id]/route.ts b/apps/web/app/api/users/[id]/route.ts index eda26d73..5ae5c373 100644 --- a/apps/web/app/api/users/[id]/route.ts +++ b/apps/web/app/api/users/[id]/route.ts @@ -74,7 +74,15 @@ export async function PATCH( const allowedFields = [ 'full_name', 'email', 'role_id', 'department', 'phone', 'avatar_url', 'timezone', 'language', - 'preferences', 'is_active' + 'preferences', 'is_active', + // HR fields + 'nik', 'employee_number', 'employment_status', + 'join_date', 'position_id', 'department_id', 'grade_id', + 'base_salary', 'bank_account', 'bank_name', 'npwp', + 'bpjs_kesehatan', 'bpjs_ketenagakerjaan', + 'emergency_contact_name', 'emergency_contact_phone', + 'emergency_contact_relation', + 'address', 'city', 'province', 'postal_code' ] for (const field of allowedFields) { diff --git a/apps/web/app/finance/customer-invoices/page.tsx b/apps/web/app/finance/customer-invoices/page.tsx new file mode 100644 index 00000000..17d7af35 --- /dev/null +++ b/apps/web/app/finance/customer-invoices/page.tsx @@ -0,0 +1,555 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { toast } from 'sonner' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@workspace/ui/components/table' +import { Button } from '@workspace/ui/components/button' +import { Input } from '@workspace/ui/components/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@workspace/ui/components/select' +import { Badge } from '@workspace/ui/components/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@workspace/ui/components/dialog' +import { + PlusIcon, + SearchIcon, + FilterIcon, + RefreshCwIcon, + EyeIcon, + PencilIcon, + Trash2Icon, + DownloadIcon, + SendIcon, + MoreVerticalIcon, + Loader2Icon, +} from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@workspace/ui/components/dropdown-menu' + +interface CustomerInvoice { + id: string + invoice_number: string + customer_name: string + customer_id: string + amount: number + tax_amount: number + total_amount: number + status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled' + issue_date: string + due_date: string + paid_date?: string + description?: string + created_at: string + updated_at: string +} + +export default function CustomerInvoicesPage() { + const [invoices, setInvoices] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [viewDialogOpen, setViewDialogOpen] = useState(false) + const [selectedInvoice, setSelectedInvoice] = useState(null) + + // Form state for new invoice + const [newInvoice, setNewInvoice] = useState({ + customer_id: '', + customer_name: '', + description: '', + amount: '', + tax_rate: '11', // PPN 11% + issue_date: new Date().toISOString().split('T')[0], + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 30 days + }) + + useEffect(() => { + fetchInvoices() + }, []) + + const fetchInvoices = async () => { + setIsLoading(true) + try { + const response = await fetch('/api/finance/customer-invoices') + if (response.ok) { + const data = await response.json() + setInvoices(data) + } else { + toast.error('Failed to fetch invoices') + } + } catch (error) { + toast.error('Error fetching invoices') + } finally { + setIsLoading(false) + } + } + + const handleCreateInvoice = async () => { + try { + const amount = parseFloat(newInvoice.amount) + const taxRate = parseFloat(newInvoice.tax_rate) + const taxAmount = amount * (taxRate / 100) + const totalAmount = amount + taxAmount + + const response = await fetch('/api/finance/customer-invoices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...newInvoice, + amount, + tax_amount: taxAmount, + total_amount: totalAmount, + }), + }) + + if (response.ok) { + toast.success('Invoice created successfully') + setCreateDialogOpen(false) + fetchInvoices() + // Reset form + setNewInvoice({ + customer_id: '', + customer_name: '', + description: '', + amount: '', + tax_rate: '11', + issue_date: new Date().toISOString().split('T')[0], + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }) + } else { + const error = await response.json() + toast.error(error.message || 'Failed to create invoice') + } + } catch (error) { + toast.error('Error creating invoice') + } + } + + const handleSendInvoice = async (invoice: CustomerInvoice) => { + try { + const response = await fetch(`/api/finance/customer-invoices/${invoice.id}/send`, { + method: 'POST', + }) + if (response.ok) { + toast.success('Invoice sent to customer') + fetchInvoices() + } else { + toast.error('Failed to send invoice') + } + } catch (error) { + toast.error('Error sending invoice') + } + } + + const handleDeleteInvoice = async (invoice: CustomerInvoice) => { + if (!confirm(`Delete invoice ${invoice.invoice_number}?`)) return + + try { + const response = await fetch(`/api/finance/customer-invoices/${invoice.id}`, { + method: 'DELETE', + }) + if (response.ok) { + toast.success('Invoice deleted') + fetchInvoices() + } else { + toast.error('Failed to delete invoice') + } + } catch (error) { + toast.error('Error deleting invoice') + } + } + + const filteredInvoices = invoices.filter((invoice) => { + const matchesSearch = + invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) || + invoice.customer_name.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter + return matchesSearch && matchesStatus + }) + + const getStatusBadge = (status: string) => { + const variants: Record = { + draft: 'secondary', + sent: 'outline', + paid: 'default', + overdue: 'destructive', + cancelled: 'secondary', + } + return {status.toUpperCase()} + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(amount) + } + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) + } + + return ( +
+ {/* Header */} +
+
+
+

Customer Invoices

+

Manage accounts receivable and customer billing

+
+
+ + ← Back to Transactions + + +
+
+
+ + {/* Main Content */} +
+ {/* Stats Cards */} +
+
+
+ {invoices.filter((i) => i.status === 'draft').length} +
+
Draft Invoices
+
+
+
+ {invoices.filter((i) => i.status === 'sent').length} +
+
Sent (Unpaid)
+
+
+
+ {invoices.filter((i) => i.status === 'paid').length} +
+
Paid Invoices
+
+
+
+ {invoices.filter((i) => i.status === 'overdue').length} +
+
Overdue
+
+
+ + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-9 bg-gray-900 border-gray-700" + /> +
+ + +
+
+ + {/* Table */} +
+ + + + Invoice Number + Customer + Amount + Tax (11%) + Total + Status + Issue Date + Due Date + Actions + + + + {isLoading ? ( + + + + Loading invoices... + + + ) : filteredInvoices.length > 0 ? ( + filteredInvoices.map((invoice) => ( + + {invoice.invoice_number} + {invoice.customer_name} + {formatCurrency(invoice.amount)} + {formatCurrency(invoice.tax_amount)} + + {formatCurrency(invoice.total_amount)} + + {getStatusBadge(invoice.status)} + {formatDate(invoice.issue_date)} + {formatDate(invoice.due_date)} + + + + + + + { setSelectedInvoice(invoice); setViewDialogOpen(true) }}> + + View + + handleSendInvoice(invoice)}> + + Send + + + handleDeleteInvoice(invoice)} + className="text-red-600" + > + + Delete + + + + + + )) + ) : ( + + + No invoices found + + + )} + +
+
+
+ + {/* Create Invoice Dialog */} + + + + Create Customer Invoice + + Create a new invoice for customer billing with PPN 11% tax + + +
+
+
+ + setNewInvoice({ ...newInvoice, customer_name: e.target.value })} + placeholder="PT. Example Customer" + className="bg-gray-900 border-gray-700" + /> +
+
+ + setNewInvoice({ ...newInvoice, issue_date: e.target.value })} + className="bg-gray-900 border-gray-700" + /> +
+
+
+ + setNewInvoice({ ...newInvoice, description: e.target.value })} + placeholder="Services rendered, products sold, etc." + className="bg-gray-900 border-gray-700" + /> +
+
+
+ + setNewInvoice({ ...newInvoice, amount: e.target.value })} + placeholder="10000000" + className="bg-gray-900 border-gray-700" + /> +
+
+ + setNewInvoice({ ...newInvoice, tax_rate: e.target.value })} + className="bg-gray-900 border-gray-700" + /> +
+
+
+ + setNewInvoice({ ...newInvoice, due_date: e.target.value })} + className="bg-gray-900 border-gray-700" + /> +
+ {newInvoice.amount && ( +
+
+ Subtotal: + {formatCurrency(parseFloat(newInvoice.amount) || 0)} +
+
+ PPN ({newInvoice.tax_rate}%): + + {formatCurrency((parseFloat(newInvoice.amount) || 0) * (parseFloat(newInvoice.tax_rate) / 100))} + +
+
+ Total: + + {formatCurrency( + (parseFloat(newInvoice.amount) || 0) * (1 + (parseFloat(newInvoice.tax_rate) / 100)) + )} + +
+
+ )} +
+ + + + +
+
+ + {/* View Invoice Dialog */} + + + + Invoice Details + + {selectedInvoice?.invoice_number} - {selectedInvoice?.customer_name} + + + {selectedInvoice && ( +
+
+
+
Status
+
{getStatusBadge(selectedInvoice.status)}
+
+
+
Invoice Date
+
{formatDate(selectedInvoice.issue_date)}
+
+
+
Due Date
+
{formatDate(selectedInvoice.due_date)}
+
+ {selectedInvoice.paid_date && ( +
+
Paid Date
+
{formatDate(selectedInvoice.paid_date)}
+
+ )} +
+ {selectedInvoice.description && ( +
+
Description
+
{selectedInvoice.description}
+
+ )} +
+
+ Subtotal: + {formatCurrency(selectedInvoice.amount)} +
+
+ Tax (PPN 11%): + {formatCurrency(selectedInvoice.tax_amount)} +
+
+ Total: + {formatCurrency(selectedInvoice.total_amount)} +
+
+
+ )} + + + + +
+
+
+ ) +} diff --git a/apps/web/app/finance/customers/page.tsx b/apps/web/app/finance/customers/page.tsx new file mode 100644 index 00000000..b32b497d --- /dev/null +++ b/apps/web/app/finance/customers/page.tsx @@ -0,0 +1,566 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { toast } from 'sonner' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@workspace/ui/components/table' +import { Button } from '@workspace/ui/components/button' +import { Input } from '@workspace/ui/components/input' +import { Badge } from '@workspace/ui/components/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@workspace/ui/components/dialog' +import { + PlusIcon, + SearchIcon, + RefreshCwIcon, + EyeIcon, + PencilIcon, + Trash2Icon, + MoreVerticalIcon, + Loader2Icon, + MailIcon, + PhoneIcon, +} from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@workspace/ui/components/dropdown-menu' + +interface Customer { + id: string + customer_code: string + customer_name: string + customer_type: string + email: string + phone: string + address?: string + tax_id?: string + payment_terms_days: number + credit_limit?: number + is_active: boolean + coa?: { + account_code: string + account_name: string + } +} + +export default function CustomersPage() { + const [customers, setCustomers] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all') + const [searchTerm, setSearchTerm] = useState('') + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [viewDialogOpen, setViewDialogOpen] = useState(false) + const [selectedCustomer, setSelectedCustomer] = useState(null) + + // Form state for new customer + const [newCustomer, setNewCustomer] = useState({ + customer_name: '', + customer_type: 'company', + email: '', + phone: '', + address: '', + tax_id: '', + payment_terms_days: 30, + credit_limit: '', + }) + + useEffect(() => { + fetchCustomers() + }, []) + + async function fetchCustomers() { + setLoading(true) + try { + const res = await fetch('/api/finance/customers') + const data = await res.json() + if (data.data) { + setCustomers(data.data) + } + } catch (error) { + console.error('Error fetching customers:', error) + toast.error('Failed to load customers') + } finally { + setLoading(false) + } + } + + const filteredCustomers = customers.filter(customer => { + const matchesFilter = + filter === 'all' ? true : + filter === 'active' ? customer.is_active : + !customer.is_active + + const matchesSearch = + customer.customer_code.toLowerCase().includes(searchTerm.toLowerCase()) || + customer.customer_name.toLowerCase().includes(searchTerm.toLowerCase()) || + customer.email.toLowerCase().includes(searchTerm.toLowerCase()) + + return matchesFilter && matchesSearch + }) + + const handleCreateCustomer = async () => { + try { + const response = await fetch('/api/finance/customers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...newCustomer, + credit_limit: newCustomer.credit_limit ? parseFloat(newCustomer.credit_limit) : undefined, + }), + }) + + if (response.ok) { + toast.success('Customer created successfully') + setCreateDialogOpen(false) + fetchCustomers() + setNewCustomer({ + customer_name: '', + customer_type: 'company', + email: '', + phone: '', + address: '', + tax_id: '', + payment_terms_days: 30, + credit_limit: '', + }) + } else { + toast.error('Failed to create customer') + } + } catch (error) { + console.error('Error creating customer:', error) + toast.error('Failed to create customer') + } + } + + const handleDeleteCustomer = async (id: string) => { + if (!confirm('Are you sure you want to delete this customer?')) return + + try { + const response = await fetch(`/api/finance/customers/${id}`, { + method: 'DELETE', + }) + + if (response.ok) { + toast.success('Customer deleted successfully') + fetchCustomers() + } else { + toast.error('Failed to delete customer') + } + } catch (error) { + console.error('Error deleting customer:', error) + toast.error('Failed to delete customer') + } + } + + return ( +
+ {/* Header */} +
+
+
+

Customer Management

+

Manage customers and client master data

+
+
+ + ← Back to Transactions + + +
+
+
+ + {/* Main Content */} +
+ {/* Filters */} +
+
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10 w-64 bg-gray-700 border-gray-600 text-white" + /> +
+ + + +
+
+ Showing {filteredCustomers.length} of {customers.length} customers +
+ +
+
+ + {/* Customers Table */} +
+ {loading ? ( +
+ + Loading customers... +
+ ) : filteredCustomers.length === 0 ? ( +
No customers found
+ ) : ( + + + + Code + Name + Type + Contact + Payment Terms + Credit Limit + COA Account + Status + Actions + + + + {filteredCustomers.map((customer) => ( + + {customer.customer_code} + {customer.customer_name} + {customer.customer_type} + +
+ + {customer.email} +
+
+ + {customer.phone} +
+
+ + {customer.payment_terms_days} days + + + {customer.credit_limit ? ( + + Rp {customer.credit_limit.toLocaleString('id-ID')} + + ) : ( + No limit + )} + + + {customer.coa ? ( +
+
{customer.coa.account_code}
+
{customer.coa.account_name}
+
+ ) : ( + Not assigned + )} +
+ + + {customer.is_active ? 'Active' : 'Inactive'} + + + + + + + + + { + setSelectedCustomer(customer) + setViewDialogOpen(true) + }} + className="text-gray-300 hover:bg-gray-700" + > + + View + + + + Edit + + + handleDeleteCustomer(customer.id)} + className="text-red-400 hover:bg-gray-700" + > + + Delete + + + + +
+ ))} +
+
+ )} +
+
+ + {/* Create Customer Dialog */} + + + + Create New Customer + + Enter the customer details + + +
+
+
+ + setNewCustomer({ ...newCustomer, customer_name: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="PT Customer Sejahtera" + /> +
+
+ + +
+
+
+
+ + setNewCustomer({ ...newCustomer, email: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="contact@customer.com" + /> +
+
+ + setNewCustomer({ ...newCustomer, phone: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="+62 812 3456 7890" + /> +
+
+
+ + setNewCustomer({ ...newCustomer, address: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="Jl. Contoh No. 123, Jakarta" + /> +
+
+
+ + setNewCustomer({ ...newCustomer, tax_id: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="01.234.567.8-901.000" + /> +
+
+ + setNewCustomer({ ...newCustomer, payment_terms_days: parseInt(e.target.value) })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="30" + /> +
+
+
+ + setNewCustomer({ ...newCustomer, credit_limit: e.target.value })} + className="bg-gray-700 border-gray-600 text-white" + placeholder="10000000" + /> +
+
+ + + + +
+
+ + {/* View Customer Dialog */} + + + + Customer Details + + {selectedCustomer && ( +
+
+
+

Customer Code

+

{selectedCustomer.customer_code}

+
+
+

Status

+ + {selectedCustomer.is_active ? 'Active' : 'Inactive'} + +
+
+
+

Customer Name

+

{selectedCustomer.customer_name}

+
+
+
+

Type

+

{selectedCustomer.customer_type}

+
+
+

Tax ID

+

{selectedCustomer.tax_id || '-'}

+
+
+
+

Contact Information

+
+
+ + {selectedCustomer.email} +
+
+ + {selectedCustomer.phone} +
+
+
+ {selectedCustomer.address && ( +
+

Address

+

{selectedCustomer.address}

+
+ )} +
+
+

Payment Terms

+

{selectedCustomer.payment_terms_days} days

+
+
+

Credit Limit

+

+ {selectedCustomer.credit_limit + ? `Rp ${selectedCustomer.credit_limit.toLocaleString('id-ID')}` + : 'No limit'} +

+
+
+ {selectedCustomer.coa && ( +
+

COA Account

+
+
{selectedCustomer.coa.account_code}
+
{selectedCustomer.coa.account_name}
+
+
+ )} +
+ )} + + + +
+
+
+ ) +} diff --git a/apps/web/app/finance/journal/new/page.tsx b/apps/web/app/finance/journal/new/page.tsx new file mode 100644 index 00000000..7b249d7a --- /dev/null +++ b/apps/web/app/finance/journal/new/page.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +interface COA { + id: string; + account_code: string; + account_name: string; + account_type: string; + normal_balance: string; +} + +interface JournalLine { + id: number; + coa_id: string; + debit_amount: string; + credit_amount: string; + line_description: string; +} + +interface FiscalPeriod { + id: string; + period_name: string; + start_date: string; + end_date: string; + status: string; +} + +export default function NewJournalEntryPage() { + const router = useRouter(); + const [coaList, setCoaList] = useState([]); + const [periods, setPeriods] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const [transactionDate, setTransactionDate] = useState(''); + const [postingDate, setPostingDate] = useState(''); + const [description, setDescription] = useState(''); + const [referenceNumber, setReferenceNumber] = useState(''); + const [selectedPeriod, setSelectedPeriod] = useState(''); + const [lines, setLines] = useState([ + { id: 1, coa_id: '', debit_amount: '', credit_amount: '', line_description: '' }, + { id: 2, coa_id: '', debit_amount: '', credit_amount: '', line_description: '' }, + ]); + + useEffect(() => { + loadData(); + // Set default dates + const today = new Date().toISOString().split('T')[0]; + setTransactionDate(today); + setPostingDate(today); + }, []); + + const loadData = async () => { + try { + const [coaRes, periodRes] = await Promise.all([ + fetch('/api/finance/coa'), + fetch('/api/finance/periods'), + ]); + const coaData = await coaRes.json(); + const periodData = await periodRes.json(); + setCoaList(coaData); + setPeriods(periodData); + // Default to first available period + if (periodData.length > 0) setSelectedPeriod(periodData[0].id); + } catch (err) { + console.error('Failed to load data:', err); + } finally { + setLoading(false); + } + }; + + const addLine = () => { + setLines([...lines, { + id: Date.now(), + coa_id: '', + debit_amount: '', + credit_amount: '', + line_description: '' + }]); + }; + + const removeLine = (id: number) => { + if (lines.length <= 2) { + setError('Minimum 2 journal lines required (PSAK double-entry)'); + return; + } + setLines(lines.filter(l => l.id !== id)); + setError(''); + }; + + const updateLine = (id: number, field: keyof JournalLine, value: string) => { + setLines(lines.map(l => { + if (l.id !== id) return l; + // If debit is entered, clear credit (and vice versa) + if (field === 'debit_amount' && value) { + return { ...l, [field]: value, credit_amount: '' }; + } + if (field === 'credit_amount' && value) { + return { ...l, [field]: value, debit_amount: '' }; + } + return { ...l, [field]: value }; + })); + }; + + const totalDebit = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0); + const totalCredit = lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0); + const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01; + const allLinesValid = lines.every(l => l.coa_id && (l.debit_amount || l.credit_amount)); + + const handleSubmit = async () => { + if (!description.trim()) { setError('Description is required'); return; } + if (!selectedPeriod) { setError('Please select a fiscal period'); return; } + if (!isBalanced) { setError(`Journal not balanced: Debit ${formatCurrency(totalDebit)} ≠ Credit ${formatCurrency(totalCredit)}`); return; } + if (!allLinesValid) { setError('All lines must have a COA account and an amount'); return; } + + setSubmitting(true); + setError(''); + try { + const payload = { + entry_number: `JE-${Date.now()}`, + transaction_date: transactionDate, + posting_date: postingDate, + description, + reference_number: referenceNumber || null, + fiscal_period_id: selectedPeriod, + currency: 'IDR', + exchange_rate: 1, + lines: lines.map((l, idx) => ({ + coa_id: l.coa_id, + debit_amount: Number(l.debit_amount) || 0, + credit_amount: Number(l.credit_amount) || 0, + line_description: l.line_description || null, + line_number: idx + 1, + })), + }; + + const res = await fetch('/api/finance/journal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.message || errData.error || 'Failed to create journal entry'); + } + + router.push('/finance/journal'); + } catch (err: any) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('id-ID', { minimumFractionDigits: 2 }).format(amount); + }; + + const getTypeColor = (type: string) => { + const colors: Record = { + asset: 'text-blue-400', + liability: 'text-red-400', + equity: 'text-purple-400', + revenue: 'text-green-400', + expense: 'text-yellow-400', + }; + return colors[type] || 'text-gray-400'; + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + ← Back to Journal Entries + +

New Journal Entry

+

Create a new double-entry journal (PSAK compliant)

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Balance Warning/Status */} +
+
+
+
+
Total Debit
+
{formatCurrency(totalDebit)}
+
+
+
Total Credit
+
{formatCurrency(totalCredit)}
+
+
+
+ {isBalanced ? 'āœ“ Balanced' : '⚠ Not Balanced'} +
+
+
+ + {/* Form Fields */} +
+
+
+ + setTransactionDate(e.target.value)} + className="w-full bg-gray-900 border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none" + /> +
+
+ + setPostingDate(e.target.value)} + className="w-full bg-gray-900 border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none" + /> +
+
+
+
+ + +
+
+ + setReferenceNumber(e.target.value)} + placeholder="e.g. INV-2026-001" + className="w-full bg-gray-900 border border-gray-600 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none" + /> +
+
+
+ +