Major expansion: HR module, CRM, integrations, packages, validation pipeline
Validate / validate (push) Successful in 34s

HR module:
- Multi-role per company (admin/manager/user/viewer/hr orthogonal)
- Employees with salary history, terminate/reactivate
- Per-company public holidays (seeded from ppraserts/thailand-open-data
  with manual fallback for unsupported years)
- Leave types (editable defaults), leave requests with approve/reject
- Per-employee leave balances (auto-seeded), remaining-days hint on
  request form, HR balance summary on requests page
- Thai-compliant payroll: SSO 5% capped, PND1 brackets, monthly WHT
- Payslip generation with editable line items, finalize/mark-paid,
  pdf-lib PDF download
- CSV export of leave per employee or company-wide

CRM & invoicing:
- Customer/supplier party database with archive
- Invoice line items, VAT 7%, status transitions, PDF generation
- Outgoing/incoming direction; incoming auto-creates linked expense

Package tracking:
- packages + package_events + shipping_accounts tables
- 8 carrier stubs (UPS/FedEx/DHL/USPS/Flash Express/Kerry/J&T/TH Post)
  with API doc references for future implementation
- Manual status updates with timeline
- Customs duty invoice flow on delivery
- Per-company carrier credentials (admin only)

Integrations scaffold:
- external_accounts + external_transactions (Kasikorn K-Biz, Ether.fi)
- Manual transaction matching to expenses

Infrastructure:
- APP_NAME env var for branding
- Soft-delete for companies and parties
- Light/dark mode toggle, dark-mode classes throughout
- pre-push hook (husky) + Gitea/GitHub Actions running svelte-check
  with --threshold warning + vite build
- npm run validate combines both checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 16:35:13 +07:00
parent 765bf0d402
commit b6f07fe4df
98 changed files with 12012 additions and 145 deletions
+445 -3
View File
@@ -15,7 +15,7 @@ import {
// ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr']);
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
// ── Users ──────────────────────────────────────────────
@@ -77,7 +77,7 @@ export const companyMembers = pgTable(
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
role: companyRoleEnum('role').notNull(),
roles: companyRoleEnum('roles').array().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
@@ -124,6 +124,7 @@ export const expenses = pgTable(
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references((): any => parties.id, { onDelete: 'set null' }),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id),
@@ -192,6 +193,422 @@ export const budgetAllocations = pgTable('budget_allocations', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Employees ──────────────────────────────────────────
export const employees = pgTable('employees', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
displayName: text('display_name'),
email: text('email'),
phone: text('phone'),
employeeCode: text('employee_code'),
position: text('position'),
department: text('department'),
hireDate: date('hire_date').notNull(),
terminationDate: date('termination_date'),
nationalId: text('national_id'),
taxId: text('tax_id'),
bankName: text('bank_name'),
bankAccount: text('bank_account'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const salaryHistory = pgTable('salary_history', {
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
effectiveFrom: date('effective_from').notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
note: text('note'),
setBy: text('set_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Public Holidays ────────────────────────────────────
export const publicHolidays = pgTable(
'public_holidays',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('public_holidays_company_date_idx').on(table.companyId, table.date)]
);
// ── Leave ──────────────────────────────────────────────
export const leaveStatusEnum = pgEnum('leave_status', ['pending', 'approved', 'rejected']);
export const leaveTypes = pgTable(
'leave_types',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
defaultDaysPerYear: numeric('default_days_per_year', { precision: 5, scale: 2 }),
isPaid: boolean('is_paid').notNull().default(true),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('leave_types_company_name_idx').on(table.companyId, table.name)]
);
export const leaveBalances = pgTable(
'leave_balances',
{
id: uuid('id').primaryKey().defaultRandom(),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
year: numeric('year', { precision: 4, scale: 0 }).notNull(),
allocated: numeric('allocated', { precision: 5, scale: 2 }).notNull().default('0'),
used: numeric('used', { precision: 5, scale: 2 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('leave_balances_emp_type_year_idx').on(
table.employeeId,
table.leaveTypeId,
table.year
)
]
);
export const leaveRequests = pgTable('leave_requests', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
leaveTypeId: uuid('leave_type_id')
.notNull()
.references(() => leaveTypes.id, { onDelete: 'cascade' }),
startDate: date('start_date').notNull(),
endDate: date('end_date').notNull(),
days: numeric('days', { precision: 5, scale: 2 }).notNull(),
reason: text('reason'),
status: leaveStatusEnum('status').notNull().default('pending'),
reviewedBy: text('reviewed_by').references(() => users.id),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Payslips ───────────────────────────────────────────
export const payslipStatusEnum = pgEnum('payslip_status', ['draft', 'finalized', 'paid']);
export const payslipLineTypeEnum = pgEnum('payslip_line_type', ['earning', 'deduction']);
export const payslips = pgTable(
'payslips',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
employeeId: uuid('employee_id')
.notNull()
.references(() => employees.id, { onDelete: 'cascade' }),
periodYear: numeric('period_year', { precision: 4, scale: 0 }).notNull(),
periodMonth: numeric('period_month', { precision: 2, scale: 0 }).notNull(),
grossSalary: numeric('gross_salary', { precision: 15, scale: 2 }).notNull(),
overtime: numeric('overtime', { precision: 15, scale: 2 }).notNull().default('0'),
bonus: numeric('bonus', { precision: 15, scale: 2 }).notNull().default('0'),
otherEarnings: numeric('other_earnings', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployee: numeric('sso_employee', { precision: 15, scale: 2 }).notNull().default('0'),
ssoEmployer: numeric('sso_employer', { precision: 15, scale: 2 }).notNull().default('0'),
incomeTax: numeric('income_tax', { precision: 15, scale: 2 }).notNull().default('0'),
otherDeductions: numeric('other_deductions', { precision: 15, scale: 2 }).notNull().default('0'),
netPay: numeric('net_pay', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: payslipStatusEnum('status').notNull().default('draft'),
finalizedAt: timestamp('finalized_at', { withTimezone: true }),
paidAt: timestamp('paid_at', { withTimezone: true }),
generatedBy: text('generated_by')
.notNull()
.references(() => users.id),
pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('payslips_emp_period_idx').on(table.employeeId, table.periodYear, table.periodMonth)
]
);
export const payslipLineItems = pgTable('payslip_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
payslipId: uuid('payslip_id')
.notNull()
.references(() => payslips.id, { onDelete: 'cascade' }),
type: payslipLineTypeEnum('type').notNull(),
label: text('label').notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
isStatutory: boolean('is_statutory').notNull().default(false)
});
// ── Parties (Customers / Suppliers) ────────────────────
export const partyTypeEnum = pgEnum('party_type', ['customer', 'supplier', 'both']);
export const parties = pgTable('parties', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
type: partyTypeEnum('type').notNull().default('customer'),
name: text('name').notNull(),
contactPerson: text('contact_person'),
email: text('email'),
phone: text('phone'),
website: text('website'),
taxId: text('tax_id'),
addressLine1: text('address_line_1'),
addressLine2: text('address_line_2'),
city: text('city'),
postalCode: text('postal_code'),
country: text('country'),
paymentTerms: text('payment_terms'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Invoices ───────────────────────────────────────────
export const invoiceDirectionEnum = pgEnum('invoice_direction', ['incoming', 'outgoing']);
export const invoiceStatusEnum = pgEnum('invoice_status', [
'draft',
'sent',
'paid',
'overdue',
'cancelled'
]);
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
partyId: uuid('party_id')
.notNull()
.references(() => parties.id, { onDelete: 'restrict' }),
direction: invoiceDirectionEnum('direction').notNull(),
invoiceNumber: text('invoice_number').notNull(),
issueDate: date('issue_date').notNull(),
dueDate: date('due_date'),
subtotal: numeric('subtotal', { precision: 15, scale: 2 }).notNull(),
vat: numeric('vat', { precision: 15, scale: 2 }).notNull().default('0'),
total: numeric('total', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull().default('THB'),
status: invoiceStatusEnum('status').notNull().default('draft'),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
notes: text('notes'),
pdfPath: text('pdf_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('invoices_company_direction_number_idx').on(
table.companyId,
table.direction,
table.invoiceNumber
)
]
);
export const invoiceLineItems = pgTable('invoice_line_items', {
id: uuid('id').primaryKey().defaultRandom(),
invoiceId: uuid('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
description: text('description').notNull(),
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
total: numeric('total', { precision: 15, scale: 2 }).notNull()
});
// ── External Integrations ──────────────────────────────
export const integrationProviderEnum = pgEnum('integration_provider', [
'kasikorn_kbiz',
'etherfi',
'manual'
]);
export const txDirectionEnum = pgEnum('tx_direction', ['credit', 'debit']);
export const externalAccounts = pgTable('external_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
provider: integrationProviderEnum('provider').notNull(),
displayName: text('display_name').notNull(),
accountIdentifier: text('account_identifier').notNull(),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
export const externalTransactions = pgTable(
'external_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.notNull()
.references(() => externalAccounts.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
externalId: text('external_id').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
direction: txDirectionEnum('direction').notNull(),
description: text('description'),
counterparty: text('counterparty'),
matchedExpenseId: uuid('matched_expense_id').references(() => expenses.id, {
onDelete: 'set null'
}),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('external_tx_account_extid_idx').on(table.accountId, table.externalId)]
);
// ── Package Tracking ───────────────────────────────────
export const packageDirectionEnum = pgEnum('package_direction', ['incoming', 'outgoing']);
export const packageStatusEnum = pgEnum('package_status', [
'pending',
'in_transit',
'out_for_delivery',
'delivered',
'exception',
'returned',
'cancelled'
]);
export const carrierEnum = pgEnum('carrier', [
'ups',
'fedex',
'dhl',
'usps',
'flash_express',
'kerry_th',
'jnt_express',
'thailand_post',
'other'
]);
export const packages = pgTable(
'packages',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
direction: packageDirectionEnum('direction').notNull(),
carrier: carrierEnum('carrier').notNull(),
trackingNumber: text('tracking_number').notNull(),
status: packageStatusEnum('status').notNull().default('pending'),
currentLocation: text('current_location'),
description: text('description'),
recipientName: text('recipient_name'),
estimatedDelivery: date('estimated_delivery'),
shippedAt: timestamp('shipped_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
weightKg: numeric('weight_kg', { precision: 10, scale: 3 }),
shippingCost: numeric('shipping_cost', { precision: 15, scale: 2 }),
currency: text('currency').notNull().default('THB'),
invoiceId: uuid('invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
customsInvoiceId: uuid('customs_invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
expenseId: uuid('expense_id').references(() => expenses.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
notes: text('notes'),
lastRefreshedAt: timestamp('last_refreshed_at', { withTimezone: true }),
createdBy: text('created_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('packages_company_carrier_tracking_idx').on(
table.companyId,
table.carrier,
table.trackingNumber
),
index('packages_company_status_idx').on(table.companyId, table.status)
]
);
export const packageEvents = pgTable(
'package_events',
{
id: uuid('id').primaryKey().defaultRandom(),
packageId: uuid('package_id')
.notNull()
.references(() => packages.id, { onDelete: 'cascade' }),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
status: packageStatusEnum('status'),
location: text('location'),
description: text('description'),
source: text('source').notNull().default('manual'),
rawPayload: text('raw_payload'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('package_events_package_idx').on(table.packageId, table.occurredAt)]
);
export const shippingAccounts = pgTable(
'shipping_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
carrier: carrierEnum('carrier').notNull(),
displayName: text('display_name'),
credentialsEncrypted: text('credentials_encrypted'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
);
// ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [
@@ -210,7 +627,32 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'expense_approved',
'expense_rejected',
'category_created',
'import_completed'
'import_completed',
'employee_created',
'employee_updated',
'employee_terminated',
'salary_changed',
'holiday_added',
'leave_type_created',
'leave_submitted',
'leave_approved',
'leave_rejected',
'payslip_generated',
'payslip_finalized',
'payslip_paid',
'party_created',
'invoice_created',
'invoice_sent',
'invoice_paid',
'integration_connected',
'integration_disconnected',
'transaction_matched',
'package_created',
'package_updated',
'package_delivered',
'package_status_refreshed',
'shipping_account_added',
'shipping_account_removed'
]);
export const companyLog = pgTable(