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
+3
View File
@@ -3,6 +3,9 @@ PORT=3000
HOST=127.0.0.1
ORIGIN=http://localhost:3000
# Branding
APP_NAME=B4L Budget
# Database
DATABASE_URL=postgresql://budget_app:password@localhost:5432/buildfor_life_budget
+26
View File
@@ -0,0 +1,26 @@
name: Validate
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Run validation (svelte-check + build)
run: npm run validate
+26
View File
@@ -0,0 +1,26 @@
name: Validate
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Run validation (svelte-check + build)
run: npm run validate
+2
View File
@@ -0,0 +1,2 @@
echo "Running validation before push (svelte-check + build)..."
npm run validate
+70
View File
@@ -11,10 +11,12 @@
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@pdf-lib/fontkit": "^1.1.1",
"chart.js": "^4.4.7",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.38.4",
"papaparse": "^5.5.2",
"pdf-lib": "^1.17.1",
"pg": "^8.13.1",
"zod": "^3.24.2"
},
@@ -26,6 +28,7 @@
"@types/papaparse": "^5.3.15",
"@types/pg": "^8.11.11",
"drizzle-kit": "^0.30.5",
"husky": "^9.1.7",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
@@ -1297,6 +1300,33 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@pdf-lib/fontkit": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@petamoriken/float16": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
@@ -2721,6 +2751,22 @@
"node": ">= 0.4"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -3108,6 +3154,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
@@ -3121,6 +3173,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+8 -3
View File
@@ -7,20 +7,24 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold warning",
"validate": "npm run check && npm run build",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"prepare": "husky"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@pdf-lib/fontkit": "^1.1.1",
"chart.js": "^4.4.7",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.38.4",
"papaparse": "^5.5.2",
"pdf-lib": "^1.17.1",
"pg": "^8.13.1",
"zod": "^3.24.2"
},
@@ -29,9 +33,10 @@
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"@types/pg": "^8.11.11",
"@types/papaparse": "^5.3.15",
"@types/pg": "^8.11.11",
"drizzle-kit": "^0.30.5",
"husky": "^9.1.7",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
+5 -4
View File
@@ -3,12 +3,13 @@
interface Props {
user: { id: string; email: string; displayName: string | null; isSystemAdmin: boolean };
companies: Array<{ companyId: string; companyName: string; role: CompanyRole }>;
companies: Array<{ companyId: string; companyName: string; roles: CompanyRole[] }>;
appName: string;
open: boolean;
onToggle: () => void;
}
let { user, companies, open, onToggle }: Props = $props();
let { user, companies, appName, open, onToggle }: Props = $props();
</script>
<aside
@@ -18,7 +19,7 @@
>
<!-- Logo -->
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
<a href="/dashboard" class="text-lg font-bold text-gray-900 dark:text-white">B4L Budget</a>
<a href="/dashboard" class="text-lg font-bold text-gray-900 dark:text-white">{appName}</a>
</div>
<!-- Navigation -->
@@ -48,7 +49,7 @@
{company.companyName[0]?.toUpperCase()}
</span>
<span class="truncate">{company.companyName}</span>
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">{company.role}</span>
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">{company.roles.join(', ')}</span>
</a>
{/each}
{/if}
+60 -12
View File
@@ -19,41 +19,89 @@ export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['
return user;
}
export async function getCompanyRole(
export async function getCompanyRoles(
userId: string,
companyId: string
): Promise<CompanyRole | null> {
): Promise<CompanyRole[] | null> {
const result = await db
.select({ role: companyMembers.role })
.select({ roles: companyMembers.roles })
.from(companyMembers)
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
.limit(1);
if (result.length === 0) return null;
return result[0].role;
return result[0].roles as CompanyRole[];
}
/** Does the role set include the target role? */
export function hasRole(roles: CompanyRole[], target: CompanyRole): boolean {
return roles.includes(target);
}
/** Does any hierarchical role in the set meet or exceed the minimum rank? `hr` does not count. */
export function meetsMinRole(roles: CompanyRole[], min: Exclude<CompanyRole, 'hr'>): boolean {
const minRank = ROLE_HIERARCHY[min];
for (const r of roles) {
if (r === 'hr') continue;
const rank = ROLE_HIERARCHY[r as Exclude<CompanyRole, 'hr'>];
if (rank >= minRank) return true;
}
return false;
}
/**
* Ensure the caller meets the minimum hierarchical role (admin>manager>user>viewer).
* System admins bypass. Returns the caller's full role set.
*/
export async function requireCompanyRole(
locals: App.Locals,
companyId: string,
minRole: CompanyRole
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
minRole: Exclude<CompanyRole, 'hr'>
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
const user = requireAuth(locals);
// System admins bypass company role checks
if (user.isSystemAdmin) {
return { user, role: 'admin' };
return { user, roles: ['admin'] };
}
const role = await getCompanyRole(user.id, companyId);
const roles = await getCompanyRoles(user.id, companyId);
if (!role) {
if (!roles || roles.length === 0) {
error(403, 'Not a member of this company');
}
if (ROLE_HIERARCHY[role] < ROLE_HIERARCHY[minRole]) {
if (!meetsMinRole(roles, minRole)) {
error(403, `Requires ${minRole} role or higher`);
}
return { user, role };
return { user, roles };
}
/**
* Ensure the caller has ANY of the listed roles. Useful for orthogonal roles like `hr`.
* System admins bypass.
*/
export async function requireCompanyRoleAny(
locals: App.Locals,
companyId: string,
anyOf: CompanyRole[]
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
const user = requireAuth(locals);
if (user.isSystemAdmin) {
return { user, roles: ['admin'] };
}
const roles = await getCompanyRoles(user.id, companyId);
if (!roles || roles.length === 0) {
error(403, 'Not a member of this company');
}
const has = roles.some((r) => anyOf.includes(r));
if (!has) {
error(403, `Requires one of: ${anyOf.join(', ')}`);
}
return { user, roles };
}
+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(
+25
View File
@@ -0,0 +1,25 @@
/**
* Ether.fi integration — STUB.
*
* TODO: implement on-chain or API-based transaction tracking for an ether.fi
* wallet address. Two possible approaches:
* 1. Query ether.fi's public API for account activity (staking rewards, deposits, withdrawals).
* Docs: https://docs.ether.fi/
* 2. Query the Ethereum blockchain directly via a public RPC (e.g. Alchemy/Infura)
* to track EETH and LIQUID transfers to/from the wallet address.
*
* Credentials shape (stored in externalAccounts.credentialsEncrypted as JSON):
* { walletAddress, apiKey? }
*/
import type { NormalizedTransaction } from './types.js';
export async function fetchTransactions(
_credentials: { walletAddress: string; apiKey?: string },
_from: Date,
_to: Date
): Promise<NormalizedTransaction[]> {
throw new Error(
'Ether.fi integration is not implemented yet. See src/lib/server/integrations/etherfi.ts for TODO.'
);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Kasikorn Bank K-Biz API integration — STUB.
*
* TODO: implement real API calls once K-Biz API credentials are obtained.
*
* K-Biz API docs (as of 2026): https://apiportal.kasikornbank.com/
* Expected flow:
* 1. OAuth2 client-credentials flow to obtain access token.
* 2. GET /v1/accounts/{accountId}/transactions?from=...&to=...
* 3. Parse response → normalize into `ExternalTransaction` shape.
*
* Credentials shape (stored in externalAccounts.credentialsEncrypted as JSON):
* { clientId, clientSecret, accountId }
*/
import type { NormalizedTransaction } from './types.js';
export async function fetchTransactions(
_credentials: { clientId: string; clientSecret: string; accountId: string },
_from: Date,
_to: Date
): Promise<NormalizedTransaction[]> {
throw new Error(
'Kasikorn K-Biz integration is not implemented yet. See src/lib/server/integrations/kasikorn.ts for TODO.'
);
}
+10
View File
@@ -0,0 +1,10 @@
export interface NormalizedTransaction {
externalId: string;
occurredAt: Date;
amount: string; // stringified numeric
currency: string;
direction: 'credit' | 'debit';
description: string | null;
counterparty: string | null;
rawPayload: string; // JSON.stringified raw provider response
}
+297
View File
@@ -0,0 +1,297 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
interface PartyBlock {
name: string;
email?: string | null;
phone?: string | null;
taxId?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
postalCode?: string | null;
country?: string | null;
}
interface InvoiceData {
company: { name: string; currency: string };
party: PartyBlock;
invoice: {
number: string;
issueDate: string;
dueDate: string | null;
direction: 'incoming' | 'outgoing';
subtotal: number;
vat: number;
total: number;
notes: string | null;
};
lineItems: {
description: string;
quantity: number;
unitPrice: number;
total: number;
}[];
}
function money(n: number, currency: string): string {
return `${currency} ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export async function generateInvoicePDF(data: InvoiceData): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([595, 842]); // A4 portrait
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const { width, height } = page.getSize();
const margin = 50;
let y = height - margin;
const black = rgb(0, 0, 0);
const grey = rgb(0.4, 0.4, 0.4);
const lightGrey = rgb(0.8, 0.8, 0.8);
// ── Header ───────────────────────────────────────────
page.drawText(data.company.name, {
x: margin,
y,
size: 18,
font: fontBold,
color: black
});
const docTitle =
data.invoice.direction === 'outgoing' ? 'INVOICE' : 'BILL / RECEIPT';
const titleWidth = fontBold.widthOfTextAtSize(docTitle, 14);
page.drawText(docTitle, {
x: width - margin - titleWidth,
y,
size: 14,
font: fontBold,
color: grey
});
y -= 20;
const invNumText = `#${data.invoice.number}`;
const invNumWidth = fontBold.widthOfTextAtSize(invNumText, 11);
page.drawText(invNumText, {
x: width - margin - invNumWidth,
y,
size: 11,
font: fontBold,
color: black
});
y -= 14;
const issuedText = `Issued: ${data.invoice.issueDate}`;
const issuedWidth = font.widthOfTextAtSize(issuedText, 9);
page.drawText(issuedText, { x: width - margin - issuedWidth, y, size: 9, font, color: grey });
if (data.invoice.dueDate) {
y -= 12;
const dueText = `Due: ${data.invoice.dueDate}`;
const dueWidth = font.widthOfTextAtSize(dueText, 9);
page.drawText(dueText, { x: width - margin - dueWidth, y, size: 9, font, color: grey });
}
// Reset y for left side after header
y = height - margin - 70;
// ── From / Bill To columns ───────────────────────────
const col1x = margin;
const col2x = margin + 270;
let yLeft = y;
let yRight = y;
if (data.invoice.direction === 'outgoing') {
// Left: From (company)
page.drawText('FROM', { x: col1x, y: yLeft, size: 8, font: fontBold, color: grey });
yLeft -= 13;
page.drawText(data.company.name, { x: col1x, y: yLeft, size: 10, font: fontBold, color: black });
yLeft -= 12;
}
// Bill-to / Supplier column
const billLabel = data.invoice.direction === 'outgoing' ? 'BILL TO' : 'FROM SUPPLIER';
page.drawText(billLabel, { x: col2x, y: yRight, size: 8, font: fontBold, color: grey });
yRight -= 13;
page.drawText(data.party.name, { x: col2x, y: yRight, size: 10, font: fontBold, color: black });
yRight -= 12;
if (data.party.email) {
page.drawText(data.party.email, { x: col2x, y: yRight, size: 9, font, color: grey });
yRight -= 12;
}
if (data.party.phone) {
page.drawText(data.party.phone, { x: col2x, y: yRight, size: 9, font, color: grey });
yRight -= 12;
}
if (data.party.taxId) {
page.drawText(`Tax ID: ${data.party.taxId}`, { x: col2x, y: yRight, size: 9, font, color: grey });
yRight -= 12;
}
if (data.party.addressLine1) {
const addrLine = data.party.addressLine2
? `${data.party.addressLine1}, ${data.party.addressLine2}`
: data.party.addressLine1;
page.drawText(addrLine, { x: col2x, y: yRight, size: 9, font, color: grey });
yRight -= 12;
}
if (data.party.city || data.party.postalCode) {
page.drawText(`${data.party.city ?? ''} ${data.party.postalCode ?? ''}`.trim(), {
x: col2x,
y: yRight,
size: 9,
font,
color: grey
});
yRight -= 12;
}
if (data.party.country) {
page.drawText(data.party.country, { x: col2x, y: yRight, size: 9, font, color: grey });
yRight -= 12;
}
y = Math.min(yLeft, yRight) - 20;
// ── Divider ──────────────────────────────────────────
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 1,
color: lightGrey
});
y -= 18;
// ── Line items table header ──────────────────────────
const col = {
desc: margin,
qty: margin + 260,
unit: margin + 320,
total: width - margin
};
page.drawText('Description', { x: col.desc, y, size: 9, font: fontBold, color: grey });
page.drawText('Qty', { x: col.qty, y, size: 9, font: fontBold, color: grey });
page.drawText('Unit Price', { x: col.unit, y, size: 9, font: fontBold, color: grey });
const totalHdr = 'Total';
const totalHdrW = fontBold.widthOfTextAtSize(totalHdr, 9);
page.drawText(totalHdr, { x: col.total - totalHdrW, y, size: 9, font: fontBold, color: grey });
y -= 6;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 0.5,
color: lightGrey
});
y -= 12;
// ── Line items ───────────────────────────────────────
for (const li of data.lineItems) {
page.drawText(li.description.slice(0, 60), { x: col.desc, y, size: 9, font, color: black });
page.drawText(li.quantity.toLocaleString('en-US'), { x: col.qty, y, size: 9, font, color: black });
const upText = money(li.unitPrice, data.invoice.number ? '' : data.company.currency).replace(/^[A-Z]+ /, '');
page.drawText(upText, { x: col.unit, y, size: 9, font, color: black });
const liTotal = money(li.total, '').trim();
const liTotalW = font.widthOfTextAtSize(liTotal, 9);
page.drawText(liTotal, { x: col.total - liTotalW, y, size: 9, font, color: black });
y -= 14;
}
y -= 4;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 0.5,
color: lightGrey
});
y -= 16;
// ── Totals box ───────────────────────────────────────
const boxX = width - margin - 200;
const boxW = 200;
let boxY = y;
const boxRows: [string, string][] = [
['Subtotal', money(data.invoice.subtotal, data.company.currency)]
];
if (data.invoice.vat > 0) {
boxRows.push(['VAT 7%', money(data.invoice.vat, data.company.currency)]);
}
const boxHeight = 16 * boxRows.length + 36;
page.drawRectangle({
x: boxX,
y: boxY - boxHeight,
width: boxW,
height: boxHeight,
borderColor: lightGrey,
borderWidth: 0.5,
color: rgb(0.98, 0.98, 0.98)
});
for (const [label, value] of boxRows) {
page.drawText(label, { x: boxX + 10, y: boxY - 12, size: 9, font, color: grey });
const valW = font.widthOfTextAtSize(value, 9);
page.drawText(value, { x: boxX + boxW - 10 - valW, y: boxY - 12, size: 9, font, color: black });
boxY -= 16;
}
boxY -= 4;
page.drawLine({
start: { x: boxX + 8, y: boxY },
end: { x: boxX + boxW - 8, y: boxY },
thickness: 0.5,
color: lightGrey
});
boxY -= 4;
const totalLabel = 'TOTAL';
const totalValue = money(data.invoice.total, data.company.currency);
page.drawText(totalLabel, { x: boxX + 10, y: boxY - 12, size: 11, font: fontBold, color: black });
const totalW = fontBold.widthOfTextAtSize(totalValue, 12);
page.drawText(totalValue, {
x: boxX + boxW - 10 - totalW,
y: boxY - 14,
size: 12,
font: fontBold,
color: black
});
y = y - boxHeight - 20;
// ── Notes ────────────────────────────────────────────
if (data.invoice.notes) {
page.drawText('Notes:', { x: margin, y, size: 9, font: fontBold, color: grey });
y -= 12;
// Simple word-wrap approximation (split by sentence)
const words = data.invoice.notes.split(' ');
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (font.widthOfTextAtSize(test, 9) > width - 2 * margin) {
page.drawText(line, { x: margin, y, size: 9, font, color: grey });
y -= 11;
line = word;
} else {
line = test;
}
}
if (line) {
page.drawText(line, { x: margin, y, size: 9, font, color: grey });
y -= 11;
}
}
// ── Footer ───────────────────────────────────────────
page.drawText('This document is computer-generated.', {
x: margin,
y: margin - 10,
size: 8,
font,
color: lightGrey
});
return pdf.save();
}
+215
View File
@@ -0,0 +1,215 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
interface PayslipData {
company: { name: string; currency: string };
employee: {
firstName: string;
lastName: string;
employeeCode: string | null;
position: string | null;
department: string | null;
nationalId: string | null;
bankName: string | null;
bankAccount: string | null;
};
period: { year: number; month: number };
earnings: { label: string; amount: number }[];
deductions: { label: string; amount: number }[];
grossTotal: number;
deductionTotal: number;
netPay: number;
generatedAt: Date;
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
function formatMoney(n: number, currency: string): string {
return `${currency} ${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export async function generatePayslipPDF(data: PayslipData): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([595, 842]); // A4 portrait
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const { width, height } = page.getSize();
const margin = 50;
let y = height - margin;
const black = rgb(0, 0, 0);
const grey = rgb(0.4, 0.4, 0.4);
const lineColor = rgb(0.8, 0.8, 0.8);
// Header
page.drawText(data.company.name, { x: margin, y, size: 18, font: fontBold, color: black });
y -= 24;
page.drawText('PAYSLIP', { x: margin, y, size: 12, font: fontBold, color: grey });
y -= 18;
page.drawText(`${MONTHS[data.period.month - 1]} ${data.period.year}`, {
x: margin,
y,
size: 11,
font,
color: grey
});
// Right side: generated date
const genText = `Generated: ${data.generatedAt.toISOString().split('T')[0]}`;
const genWidth = font.widthOfTextAtSize(genText, 9);
page.drawText(genText, { x: width - margin - genWidth, y, size: 9, font, color: grey });
y -= 24;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 1,
color: lineColor
});
y -= 20;
// Employee details (two columns)
const rows: [string, string][] = [
['Name', `${data.employee.firstName} ${data.employee.lastName}`],
['Employee Code', data.employee.employeeCode || '—'],
['Position', data.employee.position || '—'],
['Department', data.employee.department || '—'],
['National ID', data.employee.nationalId || '—'],
['Bank', data.employee.bankName ? `${data.employee.bankName}${data.employee.bankAccount ?? ''}` : '—']
];
for (const [label, value] of rows) {
page.drawText(label, { x: margin, y, size: 9, font, color: grey });
page.drawText(value, { x: margin + 120, y, size: 10, font, color: black });
y -= 15;
}
y -= 20;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 1,
color: lineColor
});
y -= 20;
// Earnings section
page.drawText('EARNINGS', { x: margin, y, size: 11, font: fontBold, color: black });
y -= 18;
for (const e of data.earnings) {
page.drawText(e.label, { x: margin + 10, y, size: 10, font, color: black });
const txt = formatMoney(e.amount, data.company.currency);
const txtW = font.widthOfTextAtSize(txt, 10);
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font, color: black });
y -= 15;
}
y -= 5;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 0.5,
color: lineColor
});
y -= 15;
page.drawText('Total Earnings', { x: margin + 10, y, size: 10, font: fontBold, color: black });
{
const txt = formatMoney(data.grossTotal, data.company.currency);
const txtW = fontBold.widthOfTextAtSize(txt, 10);
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font: fontBold, color: black });
}
y -= 30;
// Deductions section
page.drawText('DEDUCTIONS', { x: margin, y, size: 11, font: fontBold, color: black });
y -= 18;
for (const d of data.deductions) {
page.drawText(d.label, { x: margin + 10, y, size: 10, font, color: black });
const txt = formatMoney(d.amount, data.company.currency);
const txtW = font.widthOfTextAtSize(txt, 10);
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font, color: black });
y -= 15;
}
y -= 5;
page.drawLine({
start: { x: margin, y },
end: { x: width - margin, y },
thickness: 0.5,
color: lineColor
});
y -= 15;
page.drawText('Total Deductions', { x: margin + 10, y, size: 10, font: fontBold, color: black });
{
const txt = formatMoney(data.deductionTotal, data.company.currency);
const txtW = fontBold.widthOfTextAtSize(txt, 10);
page.drawText(txt, { x: width - margin - txtW, y, size: 10, font: fontBold, color: black });
}
y -= 40;
// Net pay box
page.drawRectangle({
x: margin,
y: y - 10,
width: width - 2 * margin,
height: 35,
borderColor: black,
borderWidth: 1.5,
color: rgb(0.95, 0.95, 0.95)
});
page.drawText('NET PAY', { x: margin + 15, y: y + 5, size: 12, font: fontBold, color: black });
{
const txt = formatMoney(data.netPay, data.company.currency);
const txtW = fontBold.widthOfTextAtSize(txt, 14);
page.drawText(txt, {
x: width - margin - 15 - txtW,
y: y + 3,
size: 14,
font: fontBold,
color: black
});
}
y -= 80;
// Signature lines
page.drawLine({
start: { x: margin, y },
end: { x: margin + 180, y },
thickness: 0.5,
color: black
});
page.drawLine({
start: { x: width - margin - 180, y },
end: { x: width - margin, y },
thickness: 0.5,
color: black
});
y -= 12;
page.drawText('Employer Signature', { x: margin, y, size: 9, font, color: grey });
page.drawText('Employee Signature', { x: width - margin - 180, y, size: 9, font, color: grey });
// Footer
page.drawText('This payslip is computer-generated.', {
x: margin,
y: margin - 10,
size: 8,
font,
color: grey
});
return pdf.save();
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Thailand payroll calculations.
* Sources:
* - Social Security (SSO): 5% of monthly salary, capped at THB 750 per side.
* - Income tax (PND1): 2024 progressive brackets after standard deductions.
* - Expense deduction: 50% of salary, capped at 100,000 THB/year.
* - Personal allowance: 60,000 THB/year.
* - Monthly withholding = annual tax / 12 (assumes no bonus or other incomes).
*/
const SSO_RATE = 0.05;
const SSO_SALARY_FLOOR = 1650; // SSO minimum salary
const SSO_SALARY_CAP = 15000; // SSO maximum salary for 5% calc
export function calculateSSO(monthlyGross: number): number {
if (monthlyGross <= 0) return 0;
const base = Math.min(Math.max(monthlyGross, SSO_SALARY_FLOOR), SSO_SALARY_CAP);
return Math.round(base * SSO_RATE * 100) / 100;
}
interface Bracket {
min: number;
max: number;
rate: number;
}
/** 2024 PND1 tax brackets (THB annual taxable income) */
const TAX_BRACKETS: Bracket[] = [
{ min: 0, max: 150_000, rate: 0 },
{ min: 150_000, max: 300_000, rate: 0.05 },
{ min: 300_000, max: 500_000, rate: 0.1 },
{ min: 500_000, max: 750_000, rate: 0.15 },
{ min: 750_000, max: 1_000_000, rate: 0.2 },
{ min: 1_000_000, max: 2_000_000, rate: 0.25 },
{ min: 2_000_000, max: 5_000_000, rate: 0.3 },
{ min: 5_000_000, max: Infinity, rate: 0.35 }
];
export function calculateIncomeTax(annualTaxableIncome: number): number {
if (annualTaxableIncome <= 0) return 0;
let tax = 0;
for (const b of TAX_BRACKETS) {
if (annualTaxableIncome <= b.min) break;
const portion = Math.min(annualTaxableIncome, b.max) - b.min;
tax += portion * b.rate;
}
return Math.round(tax * 100) / 100;
}
/**
* Estimate monthly withholding tax from monthly gross salary.
* Annualizes gross, applies expense + personal deductions, looks up tax, divides by 12.
*/
export function calculateMonthlyWHT(monthlyGross: number): number {
if (monthlyGross <= 0) return 0;
const annualGross = monthlyGross * 12;
const expenseDeduction = Math.min(annualGross * 0.5, 100_000);
const personalAllowance = 60_000;
const ssoAnnual = calculateSSO(monthlyGross) * 12;
const taxableIncome = Math.max(0, annualGross - expenseDeduction - personalAllowance - ssoAnnual);
const annualTax = calculateIncomeTax(taxableIncome);
return Math.round((annualTax / 12) * 100) / 100;
}
export interface PayrollCalculation {
grossEarnings: number;
ssoEmployee: number;
ssoEmployer: number;
incomeTax: number;
netPay: number;
}
/**
* Compute a complete payroll line.
* @param baseSalary monthly gross
* @param extras additional earnings (overtime + bonus + other)
* @param extraDeductions non-statutory deductions
*/
export function calculatePayroll(
baseSalary: number,
extras: number,
extraDeductions: number
): PayrollCalculation {
const grossEarnings = baseSalary + extras;
const ssoEmployee = calculateSSO(baseSalary);
const ssoEmployer = ssoEmployee;
const incomeTax = calculateMonthlyWHT(grossEarnings);
const netPay =
Math.round((grossEarnings - ssoEmployee - incomeTax - extraDeductions) * 100) / 100;
return { grossEarnings, ssoEmployee, ssoEmployer, incomeTax, netPay };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Thai public holiday source.
* Primary: ppraserts/thailand-open-data on GitHub (includes lunar holidays).
* Fallback: hardcoded list of fixed-date holidays (used if the repo is unreachable).
*/
export interface HolidayEntry {
date: string; // YYYY-MM-DD
name: string;
}
const REPO_URL = (year: number) =>
`https://raw.githubusercontent.com/ppraserts/thailand-open-data/main/data/thai-public-holidays/${year}.json`;
interface RepoEntry {
date: string;
name_th: string;
name_en: string;
type: string;
is_substitution: boolean;
note: string | null;
}
interface RepoResponse {
source?: string;
year?: number;
year_buddhist_era?: number;
total_holidays?: number;
holidays: RepoEntry[];
}
/**
* Fetch holidays for a given Gregorian year from the open-data repo.
* Throws on network/parse failure — caller should fall back to {@link thaiHolidaysFallback}.
*/
export async function fetchThaiHolidays(year: number): Promise<HolidayEntry[]> {
const res = await fetch(REPO_URL(year), {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
throw new Error(`Thai holidays fetch failed: ${res.status}`);
}
const data = (await res.json()) as RepoResponse;
if (!data || !Array.isArray(data.holidays)) {
throw new Error('Thai holidays response missing "holidays" array');
}
// Use English names; include all types (public + bank + substitution + special).
return data.holidays
.filter((e) => typeof e.date === 'string' && typeof e.name_en === 'string')
.map((e) => ({
date: e.date,
name: e.is_substitution ? `${e.name_en} (Substitution)` : e.name_en
}));
}
/**
* Try the repo first; on failure, fall back to fixed-date seed.
*/
export async function getThaiHolidays(year: number): Promise<HolidayEntry[]> {
try {
return await fetchThaiHolidays(year);
} catch (err) {
console.warn(`[thai-holidays] falling back to static list for ${year}:`, err);
return thaiHolidaysFallback(year);
}
}
/** Static fallback — fixed-date holidays only (no lunar). */
export function thaiHolidaysFallback(year: number): HolidayEntry[] {
return [
{ date: `${year}-01-01`, name: "New Year's Day" },
{ date: `${year}-04-06`, name: 'Chakri Memorial Day' },
{ date: `${year}-04-13`, name: 'Songkran Festival' },
{ date: `${year}-04-14`, name: 'Songkran Festival' },
{ date: `${year}-04-15`, name: 'Songkran Festival' },
{ date: `${year}-05-01`, name: 'Labour Day' },
{ date: `${year}-05-04`, name: 'Coronation Day' },
{ date: `${year}-06-03`, name: "Queen Suthida's Birthday" },
{ date: `${year}-07-28`, name: "HM King's Birthday" },
{ date: `${year}-08-12`, name: "HM Queen Mother's Birthday" },
{ date: `${year}-10-13`, name: 'Anniversary of the Passing of King Bhumibol' },
{ date: `${year}-10-23`, name: 'Chulalongkorn Memorial Day' },
{ date: `${year}-12-05`, name: "King Bhumibol's Birthday / Father's Day" },
{ date: `${year}-12-10`, name: 'Constitution Day' },
{ date: `${year}-12-31`, name: "New Year's Eve" }
];
}
/** Backwards-compat wrapper used by `companies/+page.server.ts` — synchronous fallback. */
export function thaiHolidaysForYear(year: number): HolidayEntry[] {
return thaiHolidaysFallback(year);
}
export const DEFAULT_LEAVE_TYPES = [
{ name: 'Annual Leave', defaultDaysPerYear: '6', isPaid: true, color: '#3B82F6' },
{ name: 'Sick Leave', defaultDaysPerYear: '30', isPaid: true, color: '#EF4444' },
{ name: 'Personal Leave', defaultDaysPerYear: '3', isPaid: true, color: '#8B5CF6' },
{ name: 'Unpaid Leave', defaultDaysPerYear: null, isPaid: false, color: '#6B7280' }
];
+21
View File
@@ -0,0 +1,21 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* DHL Shipment Tracking — Unified API — STUB.
*
* Docs: https://developer.dhl.com/api-reference/shipment-tracking
* Auth: API Key in header (DHL-API-Key)
* Track: GET https://api-eu.dhl.com/track/shipments?trackingNumber={tn}
* Free: Basic tier is free after signup.
*
* Credentials shape:
* { apiKey: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { apiKey: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'DHL tracking is not implemented yet. See src/lib/server/shipping/carriers/dhl.ts for TODO.'
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* FedEx Track API — STUB.
*
* Docs: https://developer.fedex.com/api/en-us/catalog/track.html
* Auth: OAuth 2.0 (client credentials)
* POST https://apis.fedex.com/oauth/token (grant_type=client_credentials)
* Track: POST https://apis.fedex.com/track/v1/trackingnumbers
* Body: { trackingInfo: [{ trackingNumberInfo: { trackingNumber } }] }
* Free: Developer account free; production requires approval.
*
* Credentials shape:
* { apiKey: string, apiSecret: string, accountNumber?: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { apiKey: string; apiSecret: string; accountNumber?: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'FedEx tracking is not implemented yet. See src/lib/server/shipping/carriers/fedex.ts for TODO.'
);
}
@@ -0,0 +1,22 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* Flash Express (Thailand) — STUB.
*
* Docs: https://open-api.flashexpress.com/ (merchant portal login required)
* Auth: API key + signature (HMAC-SHA256 of nonce + body + secret)
* Track: POST https://open-api.flashexpress.com/open/v1/orders/{pno}/routes
* or GET /open/v3/orders/{pno}
* Free: Merchant account required (free to register, usage is free).
*
* Credentials shape:
* { merchantId: string, apiKey: string, secret: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { merchantId: string; apiKey: string; secret: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'Flash Express tracking is not implemented yet. See src/lib/server/shipping/carriers/flash_express.ts for TODO.'
);
}
@@ -0,0 +1,22 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* J&T Express — STUB.
*
* Docs: https://open.jtexpress.co.th/ (Thailand merchant portal)
* Auth: PSID (partner ID) + signature (HMAC) + timestamp
* Track: POST https://openapi.jtexpress.my/webopenplatformapi/api/logistics/trace
* (endpoint host varies by region — .my, .th, etc.)
* Free: Merchant account required.
*
* Credentials shape:
* { apiAccount: string, privateKey: string, customerCode?: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { apiAccount: string; privateKey: string; customerCode?: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'J&T Express tracking is not implemented yet. See src/lib/server/shipping/carriers/jnt_express.ts for TODO.'
);
}
@@ -0,0 +1,21 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* Kerry Express Thailand — STUB.
*
* Docs: Limited public API. Merchant portal required for integration.
* Contact support or check https://th.kerryexpress.com/ for API access.
* Auth: Typically API key via merchant account.
* Track: GET/POST to their tracking endpoint — exact path varies by contract.
*
* Credentials shape:
* { apiKey: string, merchantCode?: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { apiKey: string; merchantCode?: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'Kerry Express (TH) tracking is not implemented yet. See src/lib/server/shipping/carriers/kerry_th.ts for TODO.'
);
}
@@ -0,0 +1,22 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* Thailand Post — STUB.
*
* Docs: No official public API for tracking as of 2026.
* Options:
* - Scrape track.thailandpost.co.th (fragile, not recommended).
* - Use an aggregator like 17track or AfterShip as a fallback.
* For now, Thailand Post packages must be updated manually.
*
* Credentials shape:
* {} (none — stub will always throw)
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: Record<string, never>
): Promise<NormalizedShipmentStatus> {
throw new Error(
'Thailand Post has no official tracking API. Please update this package status manually.'
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* UPS Tracking API — STUB.
*
* Docs: https://developer.ups.com/
* Auth: OAuth 2.0 (client credentials grant)
* Exchange clientId + clientSecret at https://onlinetools.ups.com/security/v1/oauth/token
* Track: GET https://onlinetools.ups.com/api/track/v1/details/{trackingNumber}
* Headers: Authorization: Bearer <token>, transId, transactionSrc
* Free: Developer account free; tracking API included at no cost.
*
* Credentials shape:
* { clientId: string, clientSecret: string, accountNumber?: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { clientId: string; clientSecret: string; accountNumber?: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'UPS tracking is not implemented yet. See src/lib/server/shipping/carriers/ups.ts for TODO.'
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { NormalizedShipmentStatus } from '../types.js';
/**
* USPS Tracking API — STUB.
*
* Docs: https://developer.usps.com/
* Auth: OAuth 2.0 (as of the new USPS APIs) — legacy Web Tools used userId only.
* Track: GET https://api.usps.com/tracking/v3/tracking/{trackingNumber}
* Free: Developer account free.
*
* Credentials shape:
* { clientId: string, clientSecret: string }
*/
export async function fetchStatus(
_trackingNumber: string,
_credentials: { clientId: string; clientSecret: string }
): Promise<NormalizedShipmentStatus> {
throw new Error(
'USPS tracking is not implemented yet. See src/lib/server/shipping/carriers/usps.ts for TODO.'
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { Carrier, NormalizedShipmentStatus } from './types.js';
import * as ups from './carriers/ups.js';
import * as fedex from './carriers/fedex.js';
import * as dhl from './carriers/dhl.js';
import * as usps from './carriers/usps.js';
import * as flashExpress from './carriers/flash_express.js';
import * as kerryTh from './carriers/kerry_th.js';
import * as jntExpress from './carriers/jnt_express.js';
import * as thailandPost from './carriers/thailand_post.js';
export const CARRIER_LABELS: Record<Carrier, string> = {
ups: 'UPS',
fedex: 'FedEx',
dhl: 'DHL',
usps: 'USPS',
flash_express: 'Flash Express',
kerry_th: 'Kerry Express (TH)',
jnt_express: 'J&T Express',
thailand_post: 'Thailand Post',
other: 'Other'
};
/**
* Dispatch a tracking lookup to the appropriate carrier module.
* Carrier modules are currently stubs and will throw "Not implemented".
*/
export async function fetchTrackingStatus(
carrier: Carrier,
trackingNumber: string,
credentials: unknown
): Promise<NormalizedShipmentStatus> {
switch (carrier) {
case 'ups':
return ups.fetchStatus(trackingNumber, credentials as Parameters<typeof ups.fetchStatus>[1]);
case 'fedex':
return fedex.fetchStatus(trackingNumber, credentials as Parameters<typeof fedex.fetchStatus>[1]);
case 'dhl':
return dhl.fetchStatus(trackingNumber, credentials as Parameters<typeof dhl.fetchStatus>[1]);
case 'usps':
return usps.fetchStatus(trackingNumber, credentials as Parameters<typeof usps.fetchStatus>[1]);
case 'flash_express':
return flashExpress.fetchStatus(
trackingNumber,
credentials as Parameters<typeof flashExpress.fetchStatus>[1]
);
case 'kerry_th':
return kerryTh.fetchStatus(
trackingNumber,
credentials as Parameters<typeof kerryTh.fetchStatus>[1]
);
case 'jnt_express':
return jntExpress.fetchStatus(
trackingNumber,
credentials as Parameters<typeof jntExpress.fetchStatus>[1]
);
case 'thailand_post':
return thailandPost.fetchStatus(
trackingNumber,
credentials as Parameters<typeof thailandPost.fetchStatus>[1]
);
case 'other':
throw new Error('Carrier "other" cannot be tracked automatically. Use manual updates.');
}
}
+34
View File
@@ -0,0 +1,34 @@
export type ShipmentStatus =
| 'pending'
| 'in_transit'
| 'out_for_delivery'
| 'delivered'
| 'exception'
| 'returned'
| 'cancelled';
export type Carrier =
| 'ups'
| 'fedex'
| 'dhl'
| 'usps'
| 'flash_express'
| 'kerry_th'
| 'jnt_express'
| 'thailand_post'
| 'other';
export interface ShipmentEvent {
occurredAt: Date;
status: ShipmentStatus;
location: string | null;
description: string | null;
}
export interface NormalizedShipmentStatus {
status: ShipmentStatus;
currentLocation: string | null;
estimatedDelivery: Date | null;
events: ShipmentEvent[];
rawPayload: string;
}
+14 -2
View File
@@ -1,9 +1,21 @@
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer' | 'hr';
export type ExpenseStatus = 'pending' | 'approved' | 'rejected';
export type LeaveStatus = 'pending' | 'approved' | 'rejected';
export type PartyType = 'customer' | 'supplier' | 'both';
export type InvoiceDirection = 'incoming' | 'outgoing';
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
export type PayslipStatus = 'draft' | 'finalized' | 'paid';
export type TxDirection = 'credit' | 'debit';
export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual';
export const ROLE_HIERARCHY: Record<CompanyRole, number> = {
/**
* Hierarchical roles — only these ranks. `hr` is orthogonal and excluded.
*/
export const ROLE_HIERARCHY: Record<Exclude<CompanyRole, 'hr'>, number> = {
admin: 4,
manager: 3,
user: 2,
viewer: 1
};
export const ALL_ROLES: CompanyRole[] = ['admin', 'manager', 'hr', 'user', 'viewer'];
+31
View File
@@ -0,0 +1,31 @@
/**
* Tiny CSV serializer. RFC 4180 — quotes fields that contain commas, quotes, or newlines.
*/
export function csvEscape(value: unknown): string {
if (value === null || value === undefined) return '';
const s = String(value);
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
export function csvRow(values: unknown[]): string {
return values.map(csvEscape).join(',');
}
export function csvBuild(rows: unknown[][]): string {
return rows.map(csvRow).join('\r\n');
}
/** Build a Response with a CSV download. */
export function csvResponse(csv: string, filename: string): Response {
// Prepend BOM so Excel detects UTF-8 (important for Thai characters)
const body = '\uFEFF' + csv;
return new Response(body, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Calculate working days between two dates inclusive.
* Excludes Saturdays, Sundays, and listed public holiday dates.
*/
export function calculateWorkingDays(
startDate: string | Date,
endDate: string | Date,
holidayDates: string[]
): number {
const start = typeof startDate === 'string' ? new Date(startDate) : startDate;
const end = typeof endDate === 'string' ? new Date(endDate) : endDate;
const holidaySet = new Set(holidayDates);
let days = 0;
const d = new Date(start);
d.setHours(0, 0, 0, 0);
const endMs = new Date(end).setHours(0, 0, 0, 0);
while (d.getTime() <= endMs) {
const dayOfWeek = d.getDay(); // 0=Sun, 6=Sat
const iso = d.toISOString().split('T')[0];
if (dayOfWeek !== 0 && dayOfWeek !== 6 && !holidaySet.has(iso)) {
days++;
}
d.setDate(d.getDate() + 1);
}
return days;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
.select({
companyId: companies.id,
companyName: companies.name,
role: companyMembers.role
roles: companyMembers.roles
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
+2
View File
@@ -11,6 +11,7 @@
<Sidebar
user={data.user}
companies={data.companies}
appName={data.appName}
open={sidebarOpen}
onToggle={() => (sidebarOpen = !sidebarOpen)}
/>
@@ -20,6 +21,7 @@
<header class="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800">
<button
onclick={() => (sidebarOpen = !sidebarOpen)}
aria-label="Toggle sidebar"
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 lg:hidden dark:text-gray-400 dark:hover:bg-gray-700"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+22 -3
View File
@@ -1,10 +1,11 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companies, companyMembers } from '$lib/server/db/schema.js';
import { companies, companyMembers, publicHolidays, leaveTypes } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { logCompanyEvent } from '$lib/server/audit.js';
import { requireAuth } from '$lib/server/authorization.js';
import { getThaiHolidays, DEFAULT_LEAVE_TYPES } from '$lib/server/seeds/thai-holidays.js';
export const load: PageServerLoad = async ({ locals }) => {
const user = requireAuth(locals);
@@ -16,7 +17,7 @@ export const load: PageServerLoad = async ({ locals }) => {
description: companies.description,
totalBudget: companies.totalBudget,
currency: companies.currency,
role: companyMembers.role
roles: companyMembers.roles
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
@@ -49,9 +50,27 @@ export const actions: Actions = {
await db.insert(companyMembers).values({
userId: user.id,
companyId: company.id,
role: 'admin'
roles: ['admin']
});
// Seed Thai holidays from open-data repo (current + next year), with fallback
const currentYear = new Date().getFullYear();
const [thisYear, nextYear] = await Promise.all([
getThaiHolidays(currentYear),
getThaiHolidays(currentYear + 1)
]);
const holidays = [...thisYear, ...nextYear];
if (holidays.length > 0) {
await db.insert(publicHolidays).values(
holidays.map((h) => ({ companyId: company.id, date: h.date, name: h.name }))
);
}
// Seed default leave types
await db.insert(leaveTypes).values(
DEFAULT_LEAVE_TYPES.map((lt) => ({ companyId: company.id, ...lt }))
);
await logCompanyEvent(company.id, user.id, 'company_created', `Company "${name}" created`, { currency });
const budgetNum = parseFloat(totalBudget);
+2 -2
View File
@@ -10,7 +10,7 @@
</script>
<svelte:head>
<title>Companies - B4L Budget</title>
<title>Companies - {data.appName}</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
@@ -55,7 +55,7 @@
<div class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">Budget: {formatCurrency(company.totalBudget, company.currency)}</span>
<span class="rounded-full bg-blue-100 dark:bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">
{company.role}
{company.roles.join(', ')}
</span>
</div>
</a>
@@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companies } from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireAuth, getCompanyRole } from '$lib/server/authorization.js';
import { requireAuth, getCompanyRoles } from '$lib/server/authorization.js';
import type { CompanyRole } from '$lib/types/index.js';
export const load: LayoutServerLoad = async ({ locals, params }) => {
const user = requireAuth(locals);
@@ -18,9 +19,11 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
error(404, 'Company not found');
}
const role = user.isSystemAdmin ? 'admin' : await getCompanyRole(user.id, company.id);
const roles: CompanyRole[] = user.isSystemAdmin
? ['admin']
: (await getCompanyRoles(user.id, company.id)) ?? [];
if (!role) {
if (roles.length === 0) {
error(403, 'Not a member of this company');
}
@@ -32,6 +35,6 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
totalBudget: company.totalBudget,
currency: company.currency
},
companyRole: role
companyRoles: roles
};
};
@@ -10,7 +10,29 @@
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' },
...(data.companyRole === 'admin' || data.companyRole === 'manager'
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'hr')
? [
{ href: `/companies/${data.company.id}/employees`, label: 'Employees' },
{ href: `/companies/${data.company.id}/hr/leave-requests`, label: 'Leave' },
{ href: `/companies/${data.company.id}/hr/payroll`, label: 'Payroll' },
{ href: `/companies/${data.company.id}/hr/holidays`, label: 'Holidays' }
]
: []),
...(data.companyRoles.some(r => r === 'admin' || r === 'manager')
? [
{ href: `/companies/${data.company.id}/parties`, label: 'Contacts' },
{ href: `/companies/${data.company.id}/invoices`, label: 'Invoices' }
]
: []),
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')
? [{ href: `/companies/${data.company.id}/packages`, label: 'Packages' }]
: []),
...(data.companyRoles.includes('admin')
? [
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' }
]
: []),
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
? [
{ href: `/companies/${data.company.id}/import`, label: 'Import' },
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' }
@@ -13,7 +13,7 @@
</script>
<svelte:head>
<title>{data.company.name} - B4L Budget</title>
<title>{data.company.name} - {data.appName}</title>
</svelte:head>
<div class="grid gap-6 lg:grid-cols-2">
@@ -51,7 +51,7 @@
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Projects</h2>
{#if data.companyRole !== 'viewer'}
{#if data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
<a
href="/companies/{data.company.id}/projects/new"
class="text-sm font-medium text-blue-600 hover:text-blue-700"
@@ -11,8 +11,8 @@
const remaining = $derived(total - totalSpent);
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
const unallocated = $derived(total - data.totalAllocated);
const canAllocate = $derived(data.companyRole === 'admin' || data.companyRole === 'manager');
const isAdmin = $derived(data.companyRole === 'admin');
const canAllocate = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager'));
const isAdmin = $derived(data.companyRoles.includes('admin'));
let showAddBudget = $state(false);
@@ -154,7 +154,7 @@
<div class="mb-6 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<h3 class="mb-3 font-medium text-gray-900 dark:text-white">Allocate Funds to Project</h3>
<form method="POST" action="?/allocate" use:enhance class="flex flex-wrap items-end gap-3">
<div class="flex-1">
<div class="w-64">
<label for="projectId" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Project</label>
<select
id="projectId"
@@ -167,7 +167,7 @@
{/each}
</select>
</div>
<div class="w-36">
<div class="w-48">
<label for="amount" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Amount</label>
<input
type="number"
@@ -179,7 +179,7 @@
placeholder="Negative to deallocate"
/>
</div>
<div class="flex-1">
<div class="flex-1 min-w-48">
<label for="note" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Note</label>
<input
type="text"
@@ -3,7 +3,9 @@
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const canManage = data.companyRole === 'admin' || data.companyRole === 'manager';
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
</script>
<svelte:head>
@@ -0,0 +1,65 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { employees, salaryHistory } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { and, eq, isNull, asc, lte, desc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'hr',
'manager',
'admin'
]);
await parent();
const today = new Date().toISOString().split('T')[0];
// Fetch all non-deleted employees with their current salary (latest effectiveFrom <= today)
const employeeList = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
email: employees.email,
position: employees.position,
department: employees.department,
hireDate: employees.hireDate,
isActive: employees.isActive,
terminationDate: employees.terminationDate
})
.from(employees)
.where(and(eq(employees.companyId, params.companyId), isNull(employees.deletedAt)))
.orderBy(asc(employees.firstName), asc(employees.lastName));
// For each employee, fetch current salary
const employeeIds = employeeList.map((e) => e.id);
let salaryMap: Record<string, string | null> = {};
if (employeeIds.length > 0) {
// Get the latest salary for each employee with effectiveFrom <= today
const salaryRows = await db
.selectDistinctOn([salaryHistory.employeeId], {
employeeId: salaryHistory.employeeId,
grossSalary: salaryHistory.grossSalary,
currency: salaryHistory.currency
})
.from(salaryHistory)
.where(lte(salaryHistory.effectiveFrom, today))
.orderBy(salaryHistory.employeeId, desc(salaryHistory.effectiveFrom));
for (const row of salaryRows) {
if (employeeIds.includes(row.employeeId)) {
salaryMap[row.employeeId] = row.grossSalary;
}
}
}
const result = employeeList.map((e) => ({
...e,
currentSalary: salaryMap[e.id] ?? null
}));
return { employees: result, companyRoles: roles };
};
@@ -0,0 +1,81 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import { goto } from '$app/navigation';
let { data } = $props();
const isHrOrAdmin = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('hr')
);
</script>
<svelte:head>
<title>Employees - {data.company.name}</title>
</svelte:head>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Employees</h2>
{#if isHrOrAdmin}
<a
href="/companies/{data.company.id}/employees/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New Employee
</a>
{/if}
</div>
{#if data.employees.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No employees yet. Add your first one.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Position</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Hire Date</th>
<th class="px-4 py-3 font-medium">Current Salary</th>
<th class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{#each data.employees as employee}
<tr
class="border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
onclick={() => goto(`/companies/${data.company.id}/employees/${employee.id}`)}
>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{employee.displayName ?? `${employee.firstName} ${employee.lastName}`}
<div class="text-xs text-gray-400 dark:text-gray-500 font-normal">{employee.email ?? ''}</div>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{employee.position ?? '—'}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{employee.department ?? '—'}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{employee.hireDate ? formatDate(employee.hireDate) : '—'}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{employee.currentSalary ? formatCurrency(employee.currentSalary, 'THB') : '—'}
</td>
<td class="px-4 py-3">
{#if employee.isActive}
<span class="rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 px-2 py-0.5 text-xs font-medium">
Active
</span>
{:else}
<span class="rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 px-2 py-0.5 text-xs font-medium">
Inactive
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,380 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
employees,
salaryHistory,
users,
leaveTypes,
leaveBalances,
leaveRequests
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { eq, and, isNull, desc, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'hr',
'manager',
'admin'
]);
await parent();
const [employee] = await db
.select()
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!employee) {
error(404, 'Employee not found');
}
// Salary history ordered newest first
const salaryRows = await db
.select({
id: salaryHistory.id,
effectiveFrom: salaryHistory.effectiveFrom,
grossSalary: salaryHistory.grossSalary,
currency: salaryHistory.currency,
note: salaryHistory.note,
setByUserId: salaryHistory.setBy,
setByName: users.displayName,
setByEmail: users.email,
createdAt: salaryHistory.createdAt
})
.from(salaryHistory)
.innerJoin(users, eq(salaryHistory.setBy, users.id))
.where(eq(salaryHistory.employeeId, employee.id))
.orderBy(desc(salaryHistory.effectiveFrom));
// Linked user (if any)
let linkedUser: { id: string; email: string; displayName: string | null } | null = null;
if (employee.userId) {
const [u] = await db
.select({ id: users.id, email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.id, employee.userId))
.limit(1);
linkedUser = u ?? null;
}
// Leave balances for current year
const currentYear = new Date().getFullYear();
// All leave types for the company (so we can show even ones with no balance row)
const allTypes = await db
.select({
id: leaveTypes.id,
name: leaveTypes.name,
color: leaveTypes.color,
isPaid: leaveTypes.isPaid,
defaultDaysPerYear: leaveTypes.defaultDaysPerYear
})
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(leaveTypes.name);
// Existing balance rows for this employee + year
const balanceRows = await db
.select({
id: leaveBalances.id,
leaveTypeId: leaveBalances.leaveTypeId,
allocated: leaveBalances.allocated
})
.from(leaveBalances)
.where(
and(eq(leaveBalances.employeeId, employee.id), eq(leaveBalances.year, String(currentYear)))
);
// Used = sum of approved leaveRequests this year per type
const usedRows = await db
.select({
leaveTypeId: leaveRequests.leaveTypeId,
used: sql<string>`coalesce(sum(${leaveRequests.days}), 0)`
})
.from(leaveRequests)
.where(
and(
eq(leaveRequests.employeeId, employee.id),
eq(leaveRequests.status, 'approved'),
sql`extract(year from ${leaveRequests.startDate}) = ${currentYear}`
)
)
.groupBy(leaveRequests.leaveTypeId);
const balanceByType = new Map(balanceRows.map((b) => [b.leaveTypeId, b]));
const usedByType = new Map(usedRows.map((u) => [u.leaveTypeId, parseFloat(u.used)]));
const leaveBalanceList = allTypes.map((t) => {
const bal = balanceByType.get(t.id);
const allocated = bal ? parseFloat(bal.allocated) : parseFloat(t.defaultDaysPerYear ?? '0');
const used = usedByType.get(t.id) ?? 0;
return {
balanceId: bal?.id ?? null,
leaveTypeId: t.id,
leaveTypeName: t.name,
leaveTypeColor: t.color,
isPaid: t.isPaid,
allocated,
used,
remaining: allocated - used
};
});
return {
employee,
salaryHistory: salaryRows,
linkedUser,
companyRoles: roles,
leaveBalances: leaveBalanceList,
leaveYear: currentYear
};
};
export const actions: Actions = {
updateEmployee: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: employees.id })
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Employee not found');
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
if (!firstName) return fail(400, { action: 'updateEmployee', error: 'First name is required' });
if (!lastName) return fail(400, { action: 'updateEmployee', error: 'Last name is required' });
await db
.update(employees)
.set({
firstName,
lastName,
displayName: formData.get('displayName')?.toString().trim() || null,
email: formData.get('email')?.toString().trim() || null,
phone: formData.get('phone')?.toString().trim() || null,
employeeCode: formData.get('employeeCode')?.toString().trim() || null,
position: formData.get('position')?.toString().trim() || null,
department: formData.get('department')?.toString().trim() || null,
hireDate: formData.get('hireDate')?.toString().trim() || undefined,
nationalId: formData.get('nationalId')?.toString().trim() || null,
taxId: formData.get('taxId')?.toString().trim() || null,
bankName: formData.get('bankName')?.toString().trim() || null,
bankAccount: formData.get('bankAccount')?.toString().trim() || null,
updatedAt: new Date()
})
.where(eq(employees.id, params.id));
await logCompanyEvent(
params.companyId,
user.id,
'employee_updated',
`Employee "${firstName} ${lastName}" updated`,
{ employeeId: params.id }
);
return { success: true, action: 'updateEmployee' };
},
terminate: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: employees.id, firstName: employees.firstName, lastName: employees.lastName })
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Employee not found');
const today = new Date().toISOString().split('T')[0];
await db
.update(employees)
.set({ terminationDate: today, isActive: false, updatedAt: new Date() })
.where(eq(employees.id, params.id));
await logCompanyEvent(
params.companyId,
user.id,
'employee_terminated',
`Employee "${existing.firstName} ${existing.lastName}" terminated`,
{ employeeId: params.id, terminationDate: today }
);
return { success: true, action: 'terminate' };
},
reactivate: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: employees.id, firstName: employees.firstName, lastName: employees.lastName })
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Employee not found');
await db
.update(employees)
.set({ terminationDate: null, isActive: true, updatedAt: new Date() })
.where(eq(employees.id, params.id));
return { success: true, action: 'reactivate' };
},
addSalary: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: employees.id, firstName: employees.firstName, lastName: employees.lastName })
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Employee not found');
const formData = await request.formData();
const effectiveFrom = formData.get('effectiveFrom')?.toString().trim();
const grossSalaryRaw = formData.get('grossSalary')?.toString().trim();
const note = formData.get('note')?.toString().trim() || null;
if (!effectiveFrom) return fail(400, { action: 'addSalary', error: 'Effective date is required' });
if (!grossSalaryRaw || isNaN(parseFloat(grossSalaryRaw)))
return fail(400, { action: 'addSalary', error: 'Valid gross salary is required' });
await db.insert(salaryHistory).values({
employeeId: params.id,
effectiveFrom,
grossSalary: parseFloat(grossSalaryRaw).toFixed(2),
currency: 'THB',
note,
setBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'salary_changed',
`Salary updated for "${existing.firstName} ${existing.lastName}"`,
{ employeeId: params.id, grossSalary: grossSalaryRaw, effectiveFrom }
);
return { success: true, action: 'addSalary' };
},
updateLeaveBalance: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
// Verify employee belongs to company
const [emp] = await db
.select({ id: employees.id })
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!emp) error(404, 'Employee not found');
const formData = await request.formData();
const leaveTypeId = formData.get('leaveTypeId')?.toString();
const allocatedRaw = formData.get('allocated')?.toString().trim() ?? '';
const yearRaw = formData.get('year')?.toString().trim() || String(new Date().getFullYear());
const year = parseInt(yearRaw, 10);
const allocated = parseFloat(allocatedRaw);
if (!leaveTypeId) return fail(400, { action: 'updateLeaveBalance', error: 'Leave type required' });
if (isNaN(allocated) || allocated < 0) {
return fail(400, { action: 'updateLeaveBalance', error: 'Invalid allocation' });
}
// Verify leave type belongs to company
const [lt] = await db
.select({ id: leaveTypes.id, name: leaveTypes.name })
.from(leaveTypes)
.where(and(eq(leaveTypes.id, leaveTypeId), eq(leaveTypes.companyId, params.companyId)))
.limit(1);
if (!lt) return fail(400, { action: 'updateLeaveBalance', error: 'Invalid leave type' });
// Upsert: try update first; if no row, insert
const existing = await db
.select({ id: leaveBalances.id })
.from(leaveBalances)
.where(
and(
eq(leaveBalances.employeeId, params.id),
eq(leaveBalances.leaveTypeId, leaveTypeId),
eq(leaveBalances.year, String(year))
)
)
.limit(1);
if (existing.length > 0) {
await db
.update(leaveBalances)
.set({ allocated: allocated.toFixed(2), updatedAt: new Date() })
.where(eq(leaveBalances.id, existing[0].id));
} else {
await db.insert(leaveBalances).values({
employeeId: params.id,
leaveTypeId,
year: String(year),
allocated: allocated.toFixed(2),
used: '0'
});
}
await logCompanyEvent(
params.companyId,
user.id,
'employee_updated',
`Leave allocation updated for employee (${lt.name}: ${allocated} days for ${year})`,
{ employeeId: params.id, leaveTypeId, year, allocated }
);
return { success: true, action: 'updateLeaveBalance' };
}
};
@@ -0,0 +1,561 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const isHrOrAdmin = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('hr')
);
let showEditModal = $state(false);
let showSalaryForm = $state(false);
const today = new Date().toISOString().split('T')[0];
const emp = $derived(data.employee);
const fullName = $derived(
emp.displayName ?? `${emp.firstName} ${emp.lastName}`
);
</script>
<svelte:head>
<title>{fullName} - {data.company.name}</title>
</svelte:head>
<!-- Action feedback banners -->
{#if form?.success}
<div class="mb-4 rounded-md bg-green-50 dark:bg-green-900/30 p-3 text-sm text-green-700 dark:text-green-300">
{#if form.action === 'updateEmployee'}Employee details updated.{/if}
{#if form.action === 'terminate'}Employee terminated.{/if}
{#if form.action === 'reactivate'}Employee reactivated.{/if}
{#if form.action === 'addSalary'}Salary record added.{/if}
</div>
{/if}
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- Header -->
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{fullName}</h2>
{#if emp.isActive}
<span class="rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 px-2 py-0.5 text-xs font-medium">
Active
</span>
{:else}
<span class="rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 px-2 py-0.5 text-xs font-medium">
Inactive
</span>
{/if}
</div>
{#if emp.position || emp.department}
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{[emp.position, emp.department].filter(Boolean).join(' · ')}
</p>
{/if}
</div>
{#if isHrOrAdmin}
<div class="flex gap-2 shrink-0">
<button
type="button"
onclick={() => (showEditModal = true)}
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Edit
</button>
{#if emp.isActive}
<form method="POST" action="?/terminate" use:enhance>
<button
type="submit"
onclick={(e) => { if (!confirm('Terminate this employee?')) e.preventDefault(); }}
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Terminate
</button>
</form>
{:else}
<form method="POST" action="?/reactivate" use:enhance>
<button
type="submit"
class="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
>
Reactivate
</button>
</form>
{/if}
</div>
{/if}
</div>
<!-- Employee detail card -->
<div class="mb-6 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Employee Code</p>
<p class="font-medium text-gray-900 dark:text-white">{emp.employeeCode ?? '—'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Email</p>
<p class="font-medium text-gray-900 dark:text-white">{emp.email ?? '—'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Phone</p>
<p class="font-medium text-gray-900 dark:text-white">{emp.phone ?? '—'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Hire Date</p>
<p class="font-medium text-gray-900 dark:text-white">
{emp.hireDate ? formatDate(emp.hireDate) : '—'}
</p>
</div>
{#if emp.terminationDate}
<div>
<p class="text-gray-500 dark:text-gray-400">Termination Date</p>
<p class="font-medium text-gray-900 dark:text-white">{formatDate(emp.terminationDate)}</p>
</div>
{/if}
<div>
<p class="text-gray-500 dark:text-gray-400">National ID</p>
<p class="font-medium text-gray-900 dark:text-white">{emp.nationalId ?? '—'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Tax ID</p>
<p class="font-medium text-gray-900 dark:text-white">{emp.taxId ?? '—'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Bank</p>
<p class="font-medium text-gray-900 dark:text-white">
{[emp.bankName, emp.bankAccount].filter(Boolean).join(' · ') || '—'}
</p>
</div>
{#if data.linkedUser}
<div>
<p class="text-gray-500 dark:text-gray-400">Linked User</p>
<p class="font-medium text-gray-900 dark:text-white">
{data.linkedUser.displayName ?? data.linkedUser.email}
</p>
</div>
{/if}
</div>
</div>
<!-- Leave Balances -->
<div class="mb-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">
Leave Balances <span class="text-sm font-normal text-gray-500 dark:text-gray-400">({data.leaveYear})</span>
</h3>
<a
href="/companies/{data.company.id}/employees/{data.employee.id}/leave/export?year={data.leaveYear}"
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
↓ Export CSV
</a>
</div>
{#if data.leaveBalances.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No leave types defined for this company. <a href="/companies/{data.company.id}/hr/leave-types" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">Add leave types</a> first.
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Leave Type</th>
<th class="px-4 py-3 font-medium">Allocated</th>
<th class="px-4 py-3 font-medium">Used</th>
<th class="px-4 py-3 font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{#each data.leaveBalances as bal}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
{#if bal.leaveTypeColor}
<div class="h-3 w-3 rounded-full flex-shrink-0" style="background-color: {bal.leaveTypeColor}"></div>
{/if}
<span class="font-medium text-gray-900 dark:text-white">{bal.leaveTypeName}</span>
{#if !bal.isPaid}
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-700 dark:text-gray-400">Unpaid</span>
{/if}
</div>
</td>
<td class="px-4 py-3">
{#if isHrOrAdmin}
<form
method="POST"
action="?/updateLeaveBalance"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="flex items-center gap-1"
>
<input type="hidden" name="leaveTypeId" value={bal.leaveTypeId} />
<input type="hidden" name="year" value={data.leaveYear} />
<input
type="number"
name="allocated"
min="0"
step="0.5"
value={bal.allocated}
class="w-20 rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
type="submit"
class="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
</form>
{:else}
<span class="text-gray-700 dark:text-gray-300">{bal.allocated}</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{bal.used}</td>
<td class="px-4 py-3">
<span class="font-semibold {bal.remaining < 0 ? 'text-red-600 dark:text-red-400' : bal.remaining < 1 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">
{bal.remaining}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Salary History -->
<div class="mb-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Salary History</h3>
{#if isHrOrAdmin}
<button
type="button"
onclick={() => (showSalaryForm = !showSalaryForm)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showSalaryForm ? 'Cancel' : 'Add Salary Change'}
</button>
{/if}
</div>
{#if showSalaryForm && isHrOrAdmin}
<div class="mb-4 rounded-lg border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-4">
<h4 class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">New Salary Record</h4>
<form
method="POST"
action="?/addSalary"
use:enhance={() => {
return ({ result, update }) => {
if (result.type === 'success') showSalaryForm = false;
update();
};
}}
class="grid gap-4 sm:grid-cols-3"
>
<div>
<label for="effectiveFrom" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Effective From <span class="text-red-500">*</span>
</label>
<input
type="date"
id="effectiveFrom"
name="effectiveFrom"
required
value={today}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="grossSalary" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Gross Salary (THB) <span class="text-red-500">*</span>
</label>
<input
type="number"
id="grossSalary"
name="grossSalary"
required
min="0"
step="0.01"
placeholder="0.00"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="note" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Note</label>
<input
type="text"
id="note"
name="note"
placeholder="e.g. Annual review"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="sm:col-span-3 flex justify-end">
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Salary Record
</button>
</div>
</form>
</div>
{/if}
{#if data.salaryHistory.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No salary records yet.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Effective From</th>
<th class="px-4 py-3 font-medium">Gross Salary</th>
<th class="px-4 py-3 font-medium">Note</th>
<th class="px-4 py-3 font-medium">Set By</th>
</tr>
</thead>
<tbody>
{#each data.salaryHistory as row, i}
<tr class="border-t border-gray-100 dark:border-gray-700 {i === 0 ? 'bg-blue-50/40 dark:bg-blue-900/10' : ''}">
<td class="px-4 py-3 text-gray-900 dark:text-white">
{row.effectiveFrom ? formatDate(row.effectiveFrom) : '—'}
{#if i === 0}
<span class="ml-1 text-xs text-blue-600 dark:text-blue-400">(current)</span>
{/if}
</td>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{formatCurrency(row.grossSalary, row.currency ?? 'THB')}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{row.note ?? '—'}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">
{row.setByName ?? row.setByEmail ?? '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Edit Modal -->
{#if showEditModal && isHrOrAdmin}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Edit employee"
>
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-xl">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">Edit Employee</h3>
<button
type="button"
onclick={() => (showEditModal = false)}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form
method="POST"
action="?/updateEmployee"
use:enhance={() => {
return ({ result, update }) => {
if (result.type === 'success') showEditModal = false;
update();
};
}}
class="space-y-4"
>
<!-- Personal -->
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="edit-firstName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="edit-firstName"
name="firstName"
required
value={emp.firstName}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-lastName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="edit-lastName"
name="lastName"
required
value={emp.lastName}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="sm:col-span-2">
<label for="edit-displayName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Display Name
</label>
<input
type="text"
id="edit-displayName"
name="displayName"
value={emp.displayName ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
id="edit-email"
name="email"
value={emp.email ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-phone" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input
type="tel"
id="edit-phone"
name="phone"
value={emp.phone ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-employeeCode" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Employee Code
</label>
<input
type="text"
id="edit-employeeCode"
name="employeeCode"
value={emp.employeeCode ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-hireDate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Hire Date
</label>
<input
type="date"
id="edit-hireDate"
name="hireDate"
value={emp.hireDate ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-position" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Position
</label>
<input
type="text"
id="edit-position"
name="position"
value={emp.position ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-department" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Department
</label>
<input
type="text"
id="edit-department"
name="department"
value={emp.department ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-nationalId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
National ID
</label>
<input
type="text"
id="edit-nationalId"
name="nationalId"
value={emp.nationalId ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-taxId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tax ID</label>
<input
type="text"
id="edit-taxId"
name="taxId"
value={emp.taxId ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-bankName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bank Name
</label>
<input
type="text"
id="edit-bankName"
name="bankName"
value={emp.bankName ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="edit-bankAccount" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bank Account
</label>
<input
type="text"
id="edit-bankAccount"
name="bankAccount"
value={emp.bankAccount ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-gray-100 dark:border-gray-700 pt-4">
<button
type="button"
onclick={() => (showEditModal = false)}
class="rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,163 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import {
employees,
leaveTypes,
leaveBalances,
leaveRequests,
users
} from '$lib/server/db/schema.js';
import { eq, and, isNull, sql, asc } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { csvBuild, csvResponse } from '$lib/utils/csv.js';
export const GET: RequestHandler = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'manager', 'admin']);
const yearParam = url.searchParams.get('year');
const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear();
if (isNaN(year)) error(400, 'Invalid year');
// Verify employee belongs to company
const [employee] = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
employeeCode: employees.employeeCode
})
.from(employees)
.where(
and(
eq(employees.id, params.id),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!employee) error(404, 'Employee not found');
const fullName = employee.displayName ?? `${employee.firstName} ${employee.lastName}`;
// Fetch leave types
const types = await db
.select({
id: leaveTypes.id,
name: leaveTypes.name,
isPaid: leaveTypes.isPaid,
defaultDaysPerYear: leaveTypes.defaultDaysPerYear
})
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(leaveTypes.name);
// Balances for this employee + year
const balanceRows = await db
.select({ leaveTypeId: leaveBalances.leaveTypeId, allocated: leaveBalances.allocated })
.from(leaveBalances)
.where(
and(eq(leaveBalances.employeeId, employee.id), eq(leaveBalances.year, String(year)))
);
const allocatedMap = new Map(balanceRows.map((b) => [b.leaveTypeId, parseFloat(b.allocated)]));
// Used = sum of approved requests this year per type
const usedRows = await db
.select({
leaveTypeId: leaveRequests.leaveTypeId,
used: sql<string>`coalesce(sum(${leaveRequests.days}), 0)`
})
.from(leaveRequests)
.where(
and(
eq(leaveRequests.employeeId, employee.id),
eq(leaveRequests.status, 'approved'),
sql`extract(year from ${leaveRequests.startDate}) = ${year}`
)
)
.groupBy(leaveRequests.leaveTypeId);
const usedMap = new Map(usedRows.map((u) => [u.leaveTypeId, parseFloat(u.used)]));
// All requests this year (any status)
const requests = await db
.select({
id: leaveRequests.id,
leaveTypeId: leaveRequests.leaveTypeId,
leaveTypeName: leaveTypes.name,
startDate: leaveRequests.startDate,
endDate: leaveRequests.endDate,
days: leaveRequests.days,
status: leaveRequests.status,
reason: leaveRequests.reason,
rejectionReason: leaveRequests.rejectionReason,
reviewerName: users.displayName,
reviewedAt: leaveRequests.reviewedAt,
createdAt: leaveRequests.createdAt
})
.from(leaveRequests)
.innerJoin(leaveTypes, eq(leaveRequests.leaveTypeId, leaveTypes.id))
.leftJoin(users, eq(leaveRequests.reviewedBy, users.id))
.where(
and(
eq(leaveRequests.employeeId, employee.id),
sql`extract(year from ${leaveRequests.startDate}) = ${year}`
)
)
.orderBy(asc(leaveRequests.startDate));
// Build CSV
const rows: unknown[][] = [];
rows.push(['Leave Balance Report']);
rows.push(['Employee', fullName]);
if (employee.employeeCode) rows.push(['Employee Code', employee.employeeCode]);
rows.push(['Year', year]);
rows.push(['Generated', new Date().toISOString()]);
rows.push([]);
rows.push(['BALANCE SUMMARY']);
rows.push(['Leave Type', 'Paid', 'Allocated', 'Used', 'Remaining']);
for (const t of types) {
const allocated =
allocatedMap.get(t.id) ?? (t.defaultDaysPerYear ? parseFloat(t.defaultDaysPerYear) : 0);
const used = usedMap.get(t.id) ?? 0;
rows.push([t.name, t.isPaid ? 'Yes' : 'No', allocated, used, allocated - used]);
}
rows.push([]);
rows.push(['LEAVE REQUEST HISTORY']);
rows.push([
'Leave Type',
'Start Date',
'End Date',
'Days',
'Status',
'Reason',
'Rejection Reason',
'Reviewed By',
'Reviewed At',
'Submitted At'
]);
if (requests.length === 0) {
rows.push(['(no requests for ' + year + ')']);
} else {
for (const r of requests) {
rows.push([
r.leaveTypeName,
r.startDate,
r.endDate,
r.days,
r.status,
r.reason ?? '',
r.rejectionReason ?? '',
r.reviewerName ?? '',
r.reviewedAt ? r.reviewedAt.toISOString() : '',
r.createdAt.toISOString()
]);
}
}
const safeName = fullName.replace(/[^a-zA-Z0-9_-]+/g, '_');
const filename = `leave-${safeName}-${year}.csv`;
return csvResponse(csvBuild(rows), filename);
};
@@ -0,0 +1,107 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { employees, salaryHistory, leaveTypes, leaveBalances } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
return {};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const displayName = formData.get('displayName')?.toString().trim() || null;
const email = formData.get('email')?.toString().trim() || null;
const phone = formData.get('phone')?.toString().trim() || null;
const employeeCode = formData.get('employeeCode')?.toString().trim() || null;
const position = formData.get('position')?.toString().trim() || null;
const department = formData.get('department')?.toString().trim() || null;
const hireDate =
formData.get('hireDate')?.toString().trim() ||
new Date().toISOString().split('T')[0];
const nationalId = formData.get('nationalId')?.toString().trim() || null;
const taxId = formData.get('taxId')?.toString().trim() || null;
const bankName = formData.get('bankName')?.toString().trim() || null;
const bankAccount = formData.get('bankAccount')?.toString().trim() || null;
const initialSalaryRaw = formData.get('initialSalary')?.toString().trim();
if (!firstName) return fail(400, { error: 'First name is required' });
if (!lastName) return fail(400, { error: 'Last name is required' });
if (!hireDate) return fail(400, { error: 'Hire date is required' });
const initialSalary =
initialSalaryRaw && !isNaN(parseFloat(initialSalaryRaw))
? parseFloat(initialSalaryRaw)
: 0;
const [employee] = await db
.insert(employees)
.values({
companyId: params.companyId,
firstName,
lastName,
displayName,
email,
phone,
employeeCode,
position,
department,
hireDate,
nationalId,
taxId,
bankName,
bankAccount,
isActive: true
})
.returning();
if (initialSalary > 0) {
await db.insert(salaryHistory).values({
employeeId: employee.id,
effectiveFrom: hireDate,
grossSalary: initialSalary.toFixed(2),
currency: 'THB',
note: 'Initial salary',
setBy: user.id
});
}
// Auto-seed leave balances for the current year using each leave type's default
const companyLeaveTypes = await db
.select({ id: leaveTypes.id, defaultDaysPerYear: leaveTypes.defaultDaysPerYear })
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId));
const currentYear = new Date().getFullYear();
if (companyLeaveTypes.length > 0) {
await db.insert(leaveBalances).values(
companyLeaveTypes.map((lt) => ({
employeeId: employee.id,
leaveTypeId: lt.id,
year: String(currentYear),
allocated: lt.defaultDaysPerYear ?? '0',
used: '0'
}))
);
}
await logCompanyEvent(
params.companyId,
user.id,
'employee_created',
`Employee "${firstName} ${lastName}" created`,
{ employeeId: employee.id }
);
redirect(302, `/companies/${params.companyId}/employees`);
}
};
@@ -0,0 +1,233 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
const today = new Date().toISOString().split('T')[0];
</script>
<svelte:head>
<title>New Employee</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Employee</h2>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<!-- Personal Information -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Personal Information
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="firstName"
name="firstName"
required
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="lastName"
name="lastName"
required
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="sm:col-span-2">
<label for="displayName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Display Name <span class="text-xs text-gray-400">(optional nickname or preferred name)</span>
</label>
<input
type="text"
id="displayName"
name="displayName"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
id="email"
name="email"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input
type="tel"
id="phone"
name="phone"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
<!-- Employment Details -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Employment Details
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="employeeCode" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Employee Code
</label>
<input
type="text"
id="employeeCode"
name="employeeCode"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="hireDate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Hire Date <span class="text-red-500">*</span>
</label>
<input
type="date"
id="hireDate"
name="hireDate"
required
value={today}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="position" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Position / Job Title
</label>
<input
type="text"
id="position"
name="position"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="department" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Department
</label>
<input
type="text"
id="department"
name="department"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
<!-- Tax & Bank -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Tax &amp; Bank
</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="nationalId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
National ID
</label>
<input
type="text"
id="nationalId"
name="nationalId"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="taxId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Tax ID
</label>
<input
type="text"
id="taxId"
name="taxId"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="bankName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bank Name
</label>
<input
type="text"
id="bankName"
name="bankName"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="bankAccount" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Bank Account Number
</label>
<input
type="text"
id="bankAccount"
name="bankAccount"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
<!-- Initial Salary -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Salary
</h3>
<div class="max-w-xs">
<label for="initialSalary" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Initial Gross Salary (THB) <span class="text-xs text-gray-400">(optional)</span>
</label>
<input
type="number"
id="initialSalary"
name="initialSalary"
min="0"
step="0.01"
placeholder="0.00"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
If provided, a salary record will be created effective from the hire date.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => history.back()}
class="rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Employee
</button>
</div>
</form>
</div>
@@ -6,8 +6,10 @@
import type { PageData } from './$types';
let { data } = $props();
const currency = data.company.currency;
const canApprove = data.companyRole === 'admin' || data.companyRole === 'manager';
const currency = $derived(data.company.currency);
const canApprove = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
</script>
<svelte:head>
@@ -0,0 +1,138 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { publicHolidays } from '$lib/server/db/schema.js';
import { eq, and, asc, sql } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { fetchThaiHolidays, thaiHolidaysFallback, type HolidayEntry } from '$lib/server/seeds/thai-holidays.js';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin', 'manager']);
await parent();
const holidays = await db
.select()
.from(publicHolidays)
.where(eq(publicHolidays.companyId, params.companyId))
.orderBy(asc(publicHolidays.date));
return { holidays };
};
export const actions: Actions = {
add: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const date = formData.get('date')?.toString().trim();
const name = formData.get('name')?.toString().trim();
if (!date) return fail(400, { error: 'Date is required' });
if (!name) return fail(400, { error: 'Holiday name is required' });
await db.insert(publicHolidays).values({
companyId: params.companyId,
date,
name
});
await logCompanyEvent(
params.companyId,
user.id,
'holiday_added',
`Public holiday "${name}" added on ${date}`
);
return { success: true };
},
delete: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing holiday ID' });
await db
.delete(publicHolidays)
.where(and(eq(publicHolidays.id, id), eq(publicHolidays.companyId, params.companyId)));
return { success: true };
},
importYear: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const yearStr = formData.get('year')?.toString().trim();
const useFallback = formData.get('useFallback') === '1';
const year = yearStr ? parseInt(yearStr, 10) : NaN;
if (isNaN(year) || year < 2000 || year > 2100) {
return fail(400, { error: 'Invalid year' });
}
let fetched: HolidayEntry[];
let source: 'repo' | 'fallback';
if (useFallback) {
fetched = thaiHolidaysFallback(year);
source = 'fallback';
} else {
try {
fetched = await fetchThaiHolidays(year);
source = 'repo';
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Distinguish 404 (year not yet published) from other errors
const is404 = message.includes('404');
return fail(is404 ? 404 : 502, {
unavailable: true,
year,
reason: is404
? `Year ${year} isn't available in the open-data repo yet.`
: `Couldn't reach the open-data repo: ${message}`
});
}
}
if (fetched.length === 0) {
return fail(500, { error: 'No holidays returned' });
}
// Skip dates that already exist
const existing = await db
.select({ date: publicHolidays.date })
.from(publicHolidays)
.where(
and(
eq(publicHolidays.companyId, params.companyId),
sql`extract(year from ${publicHolidays.date}) = ${year}`
)
);
const have = new Set(existing.map((e) => e.date));
const toInsert = fetched.filter((h) => !have.has(h.date));
if (toInsert.length > 0) {
await db.insert(publicHolidays).values(
toInsert.map((h) => ({ companyId: params.companyId, date: h.date, name: h.name }))
);
}
await logCompanyEvent(
params.companyId,
user.id,
'holiday_added',
`Imported ${toInsert.length} holidays for ${year} from ${source} (${have.size} already present)`
);
return {
success: true,
imported: toInsert.length,
skipped: have.size,
source,
year
};
}
};
@@ -0,0 +1,189 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('hr')
);
let importYear = $state(new Date().getFullYear());
// Group holidays by year
const byYear = $derived(() => {
const map = new Map<number, typeof data.holidays>();
for (const h of data.holidays) {
const year = parseInt(h.date.slice(0, 4));
if (!map.has(year)) map.set(year, []);
map.get(year)!.push(h);
}
return [...map.entries()].sort((a, b) => b[0] - a[0]);
});
</script>
<svelte:head>
<title>Public Holidays - {data.company.name}</title>
</svelte:head>
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Public Holidays</h2>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form && 'imported' in form}
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
Imported {form.imported} new holidays for {form.year} from {form.source === 'fallback'
? 'static fallback (fixed dates only — lunar holidays not included)'
: 'open-data repo'}. {form.skipped} already existed.
</div>
{/if}
{#if form && 'unavailable' in form && form.unavailable}
<div class="mb-4 rounded-md border border-amber-300 bg-amber-50 p-4 text-sm dark:border-amber-700/50 dark:bg-amber-900/20">
<p class="font-medium text-amber-900 dark:text-amber-200">{form.reason}</p>
<p class="mt-2 text-amber-800 dark:text-amber-300">
Use the static fallback list instead? It contains <strong>fixed-date holidays only</strong> (New Year, Chakri, Songkran, Labour Day, Coronation Day, royal birthdays, Constitution Day, etc.).
Lunar Buddhist holidays (Makha Bucha, Visakha Bucha, Asanha Bucha) won't be included — you'll need to add those manually once their dates are announced.
</p>
<form method="POST" action="?/importYear" use:enhance class="mt-3 flex gap-2">
<input type="hidden" name="year" value={form.year} />
<input type="hidden" name="useFallback" value="1" />
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
>
Use fallback for {form.year}
</button>
</form>
</div>
{/if}
{#if canManage}
<!-- Import from Thai open-data repo -->
<form
method="POST"
action="?/importYear"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="mb-4 flex flex-wrap items-end gap-3 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-700/50 dark:bg-blue-900/20"
>
<div>
<label for="importYear" class="mb-1 block text-sm font-medium text-blue-900 dark:text-blue-200">
Import Thai holidays
</label>
<input
type="number"
id="importYear"
name="year"
min="2000"
max="2100"
bind:value={importYear}
required
class="rounded-md border border-blue-300 px-3 py-2 text-sm dark:border-blue-700 dark:bg-gray-700 dark:text-white"
/>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Import year (incl. lunar holidays)
</button>
<p class="text-xs text-blue-700 dark:text-blue-300">
Source: ppraserts/thailand-open-data on GitHub
</p>
</form>
{/if}
{#if canManage}
<form method="POST" action="?/add" use:enhance class="mb-6 flex flex-wrap items-end gap-3">
<div>
<label for="date" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Date
</label>
<input
type="date"
id="date"
name="date"
required
class="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div class="flex-1 min-w-48">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Holiday Name
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="e.g. New Year's Day"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Holiday
</button>
</form>
{/if}
{#if data.holidays.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No public holidays added yet.</p>
</div>
{:else}
<div class="space-y-6">
{#each byYear() as [year, entries]}
<div>
<h3 class="mb-2 text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{year}
</h3>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Date</th>
<th class="px-4 py-3 font-medium">Name</th>
{#if canManage}
<th class="px-4 py-3 font-medium w-20"></th>
{/if}
</tr>
</thead>
<tbody>
{#each entries as holiday}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-3 text-gray-900 dark:text-white">{formatDate(holiday.date)}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{holiday.name}</td>
{#if canManage}
<td class="px-4 py-3">
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={holiday.id} />
<button
type="submit"
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</form>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,321 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
leaveRequests,
leaveTypes,
leaveBalances,
employees,
users
} from '$lib/server/db/schema.js';
import { eq, and, isNull, asc, sql } from 'drizzle-orm';
import { requireCompanyRoleAny, getCompanyRoles, requireAuth } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
const user = requireAuth(locals);
await parent();
const roles = user.isSystemAdmin
? ['admin' as const]
: (await getCompanyRoles(user.id, params.companyId)) ?? [];
if (roles.length === 0) {
return { requests: [], leaveTypes: [], employees: [], canReview: false, isHrLike: false };
}
const isHrLike = roles.some((r) => r === 'hr' || r === 'admin' || r === 'manager');
const canReview = roles.some((r) => r === 'hr' || r === 'admin');
const statusFilter = url.searchParams.get('status') ?? 'all';
// Load leave types for the form
const leaveTypeList = await db
.select()
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(asc(leaveTypes.name));
// Load employees for HR "new request" form
const employeeList = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
userId: employees.userId
})
.from(employees)
.where(and(eq(employees.companyId, params.companyId), isNull(employees.deletedAt)))
.orderBy(asc(employees.firstName), asc(employees.lastName));
// Build base query joining employee and leave type names and reviewer
type RequestRow = {
id: string;
employeeId: string;
employeeName: string;
leaveTypeId: string;
leaveTypeName: string;
leaveTypeColor: string | null;
startDate: string;
endDate: string;
days: string;
reason: string | null;
status: 'pending' | 'approved' | 'rejected';
rejectionReason: string | null;
reviewerName: string | null;
reviewedAt: Date | null;
createdAt: Date;
};
// Fetch requests with joins; reviewer names fetched separately since reviewedBy is nullable
const rows = await db
.select({
id: leaveRequests.id,
employeeId: leaveRequests.employeeId,
employeeFirstName: employees.firstName,
employeeLastName: employees.lastName,
employeeDisplayName: employees.displayName,
leaveTypeId: leaveRequests.leaveTypeId,
leaveTypeName: leaveTypes.name,
leaveTypeColor: leaveTypes.color,
startDate: leaveRequests.startDate,
endDate: leaveRequests.endDate,
days: leaveRequests.days,
reason: leaveRequests.reason,
status: leaveRequests.status,
rejectionReason: leaveRequests.rejectionReason,
reviewedBy: leaveRequests.reviewedBy,
reviewedAt: leaveRequests.reviewedAt,
createdAt: leaveRequests.createdAt
})
.from(leaveRequests)
.innerJoin(employees, eq(leaveRequests.employeeId, employees.id))
.innerJoin(leaveTypes, eq(leaveRequests.leaveTypeId, leaveTypes.id))
.where(eq(leaveRequests.companyId, params.companyId))
.orderBy(asc(leaveRequests.createdAt));
// Fetch reviewer display names for rows that have reviewedBy
const reviewerIds = [...new Set(rows.map((r) => r.reviewedBy).filter(Boolean))] as string[];
let reviewerMap: Record<string, string> = {};
for (const rid of reviewerIds) {
const [u] = await db
.select({ id: users.id, displayName: users.displayName, username: users.username })
.from(users)
.where(eq(users.id, rid))
.limit(1);
if (u) reviewerMap[u.id] = u.displayName ?? u.username ?? u.id;
}
// Filter by status
let filtered = rows;
if (statusFilter !== 'all') {
filtered = rows.filter((r) => r.status === statusFilter);
}
// If not HR/admin/manager, only show own requests
if (!isHrLike) {
const [myEmployee] = await db
.select({ id: employees.id })
.from(employees)
.where(
and(
eq(employees.companyId, params.companyId),
eq(employees.userId, user.id),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!myEmployee) {
filtered = [];
} else {
filtered = filtered.filter((r) => r.employeeId === myEmployee.id);
}
}
const requests: RequestRow[] = filtered.map((r) => ({
id: r.id,
employeeId: r.employeeId,
employeeName: r.employeeDisplayName ?? `${r.employeeFirstName} ${r.employeeLastName}`,
leaveTypeId: r.leaveTypeId,
leaveTypeName: r.leaveTypeName,
leaveTypeColor: r.leaveTypeColor,
startDate: r.startDate,
endDate: r.endDate,
days: r.days,
reason: r.reason,
status: r.status,
rejectionReason: r.rejectionReason,
reviewerName: r.reviewedBy ? (reviewerMap[r.reviewedBy] ?? null) : null,
reviewedAt: r.reviewedAt,
createdAt: r.createdAt
}));
// HR balance summary (one row per active employee with allocated/used/remaining per type)
let balanceSummary: Array<{
employeeId: string;
employeeName: string;
balances: Array<{
leaveTypeId: string;
leaveTypeName: string;
leaveTypeColor: string | null;
allocated: number;
used: number;
remaining: number;
}>;
}> = [];
const currentYear = new Date().getFullYear();
if (isHrLike) {
const allBalances = await db
.select({
employeeId: leaveBalances.employeeId,
leaveTypeId: leaveBalances.leaveTypeId,
allocated: leaveBalances.allocated
})
.from(leaveBalances)
.innerJoin(employees, eq(leaveBalances.employeeId, employees.id))
.where(
and(
eq(employees.companyId, params.companyId),
eq(leaveBalances.year, String(currentYear))
)
);
const allUsed = await db
.select({
employeeId: leaveRequests.employeeId,
leaveTypeId: leaveRequests.leaveTypeId,
used: sql<string>`coalesce(sum(${leaveRequests.days}), 0)`
})
.from(leaveRequests)
.where(
and(
eq(leaveRequests.companyId, params.companyId),
eq(leaveRequests.status, 'approved'),
sql`extract(year from ${leaveRequests.startDate}) = ${currentYear}`
)
)
.groupBy(leaveRequests.employeeId, leaveRequests.leaveTypeId);
const allocatedMap = new Map<string, Map<string, number>>();
for (const b of allBalances) {
if (!allocatedMap.has(b.employeeId)) allocatedMap.set(b.employeeId, new Map());
allocatedMap.get(b.employeeId)!.set(b.leaveTypeId, parseFloat(b.allocated));
}
const usedMap = new Map<string, Map<string, number>>();
for (const u of allUsed) {
if (!usedMap.has(u.employeeId)) usedMap.set(u.employeeId, new Map());
usedMap.get(u.employeeId)!.set(u.leaveTypeId, parseFloat(u.used));
}
balanceSummary = employeeList.map((e) => ({
employeeId: e.id,
employeeName: e.displayName ?? `${e.firstName} ${e.lastName}`,
balances: leaveTypeList.map((lt) => {
const allocated =
allocatedMap.get(e.id)?.get(lt.id) ??
(lt.defaultDaysPerYear ? parseFloat(lt.defaultDaysPerYear) : 0);
const used = usedMap.get(e.id)?.get(lt.id) ?? 0;
return {
leaveTypeId: lt.id,
leaveTypeName: lt.name,
leaveTypeColor: lt.color,
allocated,
used,
remaining: allocated - used
};
})
}));
}
return {
requests,
leaveTypes: leaveTypeList,
employees: employeeList,
canReview,
isHrLike,
statusFilter,
balanceSummary,
leaveYear: currentYear
};
};
export const actions: Actions = {
approve: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing request ID' });
const [existing] = await db
.select({ id: leaveRequests.id, employeeId: leaveRequests.employeeId })
.from(leaveRequests)
.where(and(eq(leaveRequests.id, id), eq(leaveRequests.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Leave request not found' });
await db
.update(leaveRequests)
.set({
status: 'approved',
reviewedBy: user.id,
reviewedAt: new Date(),
updatedAt: new Date()
})
.where(eq(leaveRequests.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'leave_approved',
`Leave request approved`,
{ leaveRequestId: id, employeeId: existing.employeeId }
);
return { success: true };
},
reject: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const id = formData.get('id')?.toString();
const rejectionReason = formData.get('rejectionReason')?.toString().trim() || null;
if (!id) return fail(400, { error: 'Missing request ID' });
const [existing] = await db
.select({ id: leaveRequests.id, employeeId: leaveRequests.employeeId })
.from(leaveRequests)
.where(and(eq(leaveRequests.id, id), eq(leaveRequests.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Leave request not found' });
await db
.update(leaveRequests)
.set({
status: 'rejected',
reviewedBy: user.id,
reviewedAt: new Date(),
rejectionReason,
updatedAt: new Date()
})
.where(eq(leaveRequests.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'leave_rejected',
`Leave request rejected`,
{ leaveRequestId: id, employeeId: existing.employeeId, rejectionReason }
);
return { success: true };
}
};
@@ -0,0 +1,206 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
const statusFilter = $derived(data.statusFilter ?? 'all');
const statusFilters = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' }
];
function statusBadgeClass(status: string) {
if (status === 'approved') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
if (status === 'rejected') return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
}
</script>
<svelte:head>
<title>Leave Requests - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Leave Requests</h2>
<a
href="/companies/{data.company.id}/hr/leave-requests/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New Request
</a>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- HR Balance summary -->
{#if data.isHrLike && data.balanceSummary && data.balanceSummary.length > 0}
<div class="mb-2 flex justify-end">
<a
href="/companies/{data.company.id}/hr/leave-requests/export?year={data.leaveYear}"
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
↓ Export company CSV
</a>
</div>
<details class="mb-4 rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700/50">
Leave Balances Summary ({data.leaveYear}) — {data.balanceSummary.length} employee{data.balanceSummary.length === 1 ? '' : 's'}
</summary>
<div class="overflow-x-auto border-t border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-2 font-medium">Employee</th>
{#each data.leaveTypes as lt}
<th class="px-4 py-2 font-medium">
<div class="flex items-center gap-1.5">
{#if lt.color}
<div class="h-2 w-2 rounded-full" style="background-color: {lt.color}"></div>
{/if}
<span>{lt.name}</span>
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each data.balanceSummary as row}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-2 font-medium text-gray-900 dark:text-white">{row.employeeName}</td>
{#each row.balances as b}
<td class="px-4 py-2 text-xs">
<span class="font-semibold {b.remaining < 0 ? 'text-red-600 dark:text-red-400' : b.remaining < 1 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">
{b.remaining}
</span>
<span class="text-gray-400 dark:text-gray-500">/ {b.allocated}</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</details>
{/if}
<!-- Status filter pills -->
<div class="mb-4 flex flex-wrap gap-2">
{#each statusFilters as sf}
<a
href="?status={sf.value}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{statusFilter === sf.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{sf.label}
</a>
{/each}
</div>
{#if data.requests.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No leave requests found.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
{#if data.isHrLike}
<th class="px-4 py-3 font-medium">Employee</th>
{/if}
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">Dates</th>
<th class="px-4 py-3 font-medium">Days</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Reason / Note</th>
<th class="px-4 py-3 font-medium">Reviewer</th>
{#if data.canReview}
<th class="px-4 py-3 font-medium w-40"></th>
{/if}
</tr>
</thead>
<tbody>
{#each data.requests as req}
<tr class="border-t border-gray-100 dark:border-gray-700">
{#if data.isHrLike}
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{req.employeeName}</td>
{/if}
<td class="px-4 py-3">
<div class="flex items-center gap-1.5">
{#if req.leaveTypeColor}
<div class="h-2.5 w-2.5 rounded-full flex-shrink-0" style="background-color: {req.leaveTypeColor}"></div>
{/if}
<span class="text-gray-700 dark:text-gray-300">{req.leaveTypeName}</span>
</div>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300 whitespace-nowrap">
{formatDate(req.startDate)}
{#if req.startDate !== req.endDate}
{formatDate(req.endDate)}
{/if}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">{req.days}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(req.status)}">
{req.status.charAt(0).toUpperCase() + req.status.slice(1)}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300 max-w-xs">
{#if req.reason}
<span class="line-clamp-2">{req.reason}</span>
{/if}
{#if req.rejectionReason}
<span class="text-red-600 dark:text-red-400 text-xs">{req.rejectionReason}</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{req.reviewerName ?? '—'}
{#if req.reviewedAt}
<div class="text-gray-400 dark:text-gray-500">{formatDate(req.reviewedAt)}</div>
{/if}
</td>
{#if data.canReview}
<td class="px-4 py-3">
{#if req.status === 'pending'}
<div class="flex gap-2">
<form method="POST" action="?/approve" use:enhance>
<input type="hidden" name="id" value={req.id} />
<button
type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-300 dark:hover:bg-green-900/60"
>
Approve
</button>
</form>
<form method="POST" action="?/reject" use:enhance>
<input type="hidden" name="id" value={req.id} />
<button
type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/60"
>
Reject
</button>
</form>
</div>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,198 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companies,
employees,
leaveTypes,
leaveBalances,
leaveRequests,
users
} from '$lib/server/db/schema.js';
import { eq, and, isNull, sql, asc } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { csvBuild, csvResponse } from '$lib/utils/csv.js';
export const GET: RequestHandler = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'manager', 'admin']);
const yearParam = url.searchParams.get('year');
const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear();
if (isNaN(year)) error(400, 'Invalid year');
const [company] = await db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) error(404, 'Company not found');
const empList = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
employeeCode: employees.employeeCode,
department: employees.department,
position: employees.position
})
.from(employees)
.where(and(eq(employees.companyId, params.companyId), isNull(employees.deletedAt)))
.orderBy(employees.firstName, employees.lastName);
const types = await db
.select({
id: leaveTypes.id,
name: leaveTypes.name,
defaultDaysPerYear: leaveTypes.defaultDaysPerYear
})
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(leaveTypes.name);
const allBalances = await db
.select({
employeeId: leaveBalances.employeeId,
leaveTypeId: leaveBalances.leaveTypeId,
allocated: leaveBalances.allocated
})
.from(leaveBalances)
.innerJoin(employees, eq(leaveBalances.employeeId, employees.id))
.where(
and(
eq(employees.companyId, params.companyId),
eq(leaveBalances.year, String(year))
)
);
const allUsed = await db
.select({
employeeId: leaveRequests.employeeId,
leaveTypeId: leaveRequests.leaveTypeId,
used: sql<string>`coalesce(sum(${leaveRequests.days}), 0)`
})
.from(leaveRequests)
.where(
and(
eq(leaveRequests.companyId, params.companyId),
eq(leaveRequests.status, 'approved'),
sql`extract(year from ${leaveRequests.startDate}) = ${year}`
)
)
.groupBy(leaveRequests.employeeId, leaveRequests.leaveTypeId);
const allocatedMap = new Map<string, Map<string, number>>();
for (const b of allBalances) {
if (!allocatedMap.has(b.employeeId)) allocatedMap.set(b.employeeId, new Map());
allocatedMap.get(b.employeeId)!.set(b.leaveTypeId, parseFloat(b.allocated));
}
const usedMap = new Map<string, Map<string, number>>();
for (const u of allUsed) {
if (!usedMap.has(u.employeeId)) usedMap.set(u.employeeId, new Map());
usedMap.get(u.employeeId)!.set(u.leaveTypeId, parseFloat(u.used));
}
// All requests for the company for the year
const requests = await db
.select({
id: leaveRequests.id,
employeeId: leaveRequests.employeeId,
employeeFirstName: employees.firstName,
employeeLastName: employees.lastName,
employeeDisplayName: employees.displayName,
employeeCode: employees.employeeCode,
leaveTypeName: leaveTypes.name,
startDate: leaveRequests.startDate,
endDate: leaveRequests.endDate,
days: leaveRequests.days,
status: leaveRequests.status,
reason: leaveRequests.reason,
rejectionReason: leaveRequests.rejectionReason,
reviewerName: users.displayName,
reviewedAt: leaveRequests.reviewedAt,
createdAt: leaveRequests.createdAt
})
.from(leaveRequests)
.innerJoin(employees, eq(leaveRequests.employeeId, employees.id))
.innerJoin(leaveTypes, eq(leaveRequests.leaveTypeId, leaveTypes.id))
.leftJoin(users, eq(leaveRequests.reviewedBy, users.id))
.where(
and(
eq(leaveRequests.companyId, params.companyId),
sql`extract(year from ${leaveRequests.startDate}) = ${year}`
)
)
.orderBy(asc(employees.firstName), asc(leaveRequests.startDate));
// Build CSV
const rows: unknown[][] = [];
rows.push(['Company Leave Report']);
rows.push(['Company', company.name]);
rows.push(['Year', year]);
rows.push(['Employees', empList.length]);
rows.push(['Generated', new Date().toISOString()]);
rows.push([]);
rows.push(['BALANCE MATRIX']);
const headerRow: unknown[] = ['Employee', 'Code', 'Department', 'Position'];
for (const t of types) {
headerRow.push(`${t.name} Allocated`, `${t.name} Used`, `${t.name} Remaining`);
}
rows.push(headerRow);
for (const e of empList) {
const fullName = e.displayName ?? `${e.firstName} ${e.lastName}`;
const row: unknown[] = [fullName, e.employeeCode ?? '', e.department ?? '', e.position ?? ''];
for (const t of types) {
const allocated =
allocatedMap.get(e.id)?.get(t.id) ??
(t.defaultDaysPerYear ? parseFloat(t.defaultDaysPerYear) : 0);
const used = usedMap.get(e.id)?.get(t.id) ?? 0;
row.push(allocated, used, allocated - used);
}
rows.push(row);
}
rows.push([]);
rows.push(['LEAVE REQUEST HISTORY']);
rows.push([
'Employee',
'Code',
'Leave Type',
'Start Date',
'End Date',
'Days',
'Status',
'Reason',
'Rejection Reason',
'Reviewed By',
'Reviewed At',
'Submitted At'
]);
if (requests.length === 0) {
rows.push(['(no requests for ' + year + ')']);
} else {
for (const r of requests) {
const fullName = r.employeeDisplayName ?? `${r.employeeFirstName} ${r.employeeLastName}`;
rows.push([
fullName,
r.employeeCode ?? '',
r.leaveTypeName,
r.startDate,
r.endDate,
r.days,
r.status,
r.reason ?? '',
r.rejectionReason ?? '',
r.reviewerName ?? '',
r.reviewedAt ? r.reviewedAt.toISOString() : '',
r.createdAt.toISOString()
]);
}
}
const safeCompany = company.name.replace(/[^a-zA-Z0-9_-]+/g, '_');
const filename = `leave-${safeCompany}-${year}.csv`;
return csvResponse(csvBuild(rows), filename);
};
@@ -0,0 +1,261 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
leaveRequests,
leaveTypes,
leaveBalances,
employees,
publicHolidays
} from '$lib/server/db/schema.js';
import { eq, and, isNull, asc, sql } from 'drizzle-orm';
import { requireAuth, getCompanyRoles } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { calculateWorkingDays } from '$lib/utils/leave.js';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
const user = requireAuth(locals);
await parent();
const roles = user.isSystemAdmin
? ['admin' as const]
: (await getCompanyRoles(user.id, params.companyId)) ?? [];
if (roles.length === 0) {
return { leaveTypes: [], employees: [], holidayDates: [], myEmployeeId: null, isHrLike: false, noEmployeeRecord: false };
}
const isHrLike = roles.some((r) => r === 'hr' || r === 'admin' || r === 'manager');
const leaveTypeList = await db
.select()
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(asc(leaveTypes.name));
const employeeList = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
userId: employees.userId
})
.from(employees)
.where(and(eq(employees.companyId, params.companyId), isNull(employees.deletedAt)))
.orderBy(asc(employees.firstName), asc(employees.lastName));
const holidays = await db
.select({ date: publicHolidays.date })
.from(publicHolidays)
.where(eq(publicHolidays.companyId, params.companyId));
const holidayDates = holidays.map((h) => h.date);
// Identify the current user's own employee record
let myEmployeeId: string | null = null;
let noEmployeeRecord = false;
if (!isHrLike) {
const [myEmployee] = await db
.select({ id: employees.id })
.from(employees)
.where(
and(
eq(employees.companyId, params.companyId),
eq(employees.userId, user.id),
isNull(employees.deletedAt)
)
)
.limit(1);
if (myEmployee) {
myEmployeeId = myEmployee.id;
} else {
noEmployeeRecord = true;
}
}
// Compute balance map: { [employeeId][leaveTypeId] = remaining }
const currentYear = new Date().getFullYear();
const allBalances = await db
.select({
employeeId: leaveBalances.employeeId,
leaveTypeId: leaveBalances.leaveTypeId,
allocated: leaveBalances.allocated
})
.from(leaveBalances)
.innerJoin(employees, eq(leaveBalances.employeeId, employees.id))
.where(
and(eq(employees.companyId, params.companyId), eq(leaveBalances.year, String(currentYear)))
);
const allUsed = await db
.select({
employeeId: leaveRequests.employeeId,
leaveTypeId: leaveRequests.leaveTypeId,
used: sql<string>`coalesce(sum(${leaveRequests.days}), 0)`
})
.from(leaveRequests)
.where(
and(
eq(leaveRequests.companyId, params.companyId),
eq(leaveRequests.status, 'approved'),
sql`extract(year from ${leaveRequests.startDate}) = ${currentYear}`
)
)
.groupBy(leaveRequests.employeeId, leaveRequests.leaveTypeId);
const balances: Record<string, Record<string, { allocated: number; used: number; remaining: number }>> = {};
// Seed with leave-type defaults so types without a balance row still appear
for (const e of employeeList) {
balances[e.id] = {};
for (const lt of leaveTypeList) {
const def = lt.defaultDaysPerYear ? parseFloat(lt.defaultDaysPerYear) : 0;
balances[e.id][lt.id] = { allocated: def, used: 0, remaining: def };
}
}
for (const b of allBalances) {
if (balances[b.employeeId]?.[b.leaveTypeId]) {
balances[b.employeeId][b.leaveTypeId].allocated = parseFloat(b.allocated);
balances[b.employeeId][b.leaveTypeId].remaining = parseFloat(b.allocated);
}
}
for (const u of allUsed) {
if (balances[u.employeeId]?.[u.leaveTypeId]) {
const used = parseFloat(u.used);
balances[u.employeeId][u.leaveTypeId].used = used;
balances[u.employeeId][u.leaveTypeId].remaining -= used;
}
}
return {
leaveTypes: leaveTypeList,
employees: employeeList,
holidayDates,
myEmployeeId,
isHrLike,
noEmployeeRecord,
balances,
leaveYear: currentYear
};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const user = requireAuth(locals);
const roles = user.isSystemAdmin
? ['admin' as const]
: (await getCompanyRoles(user.id, params.companyId)) ?? [];
if (roles.length === 0) {
return fail(403, { error: 'Not a member of this company' });
}
const isHrLike = roles.some((r) => r === 'hr' || r === 'admin' || r === 'manager');
const formData = await request.formData();
let employeeId = formData.get('employeeId')?.toString();
const leaveTypeId = formData.get('leaveTypeId')?.toString();
const startDate = formData.get('startDate')?.toString().trim();
const endDate = formData.get('endDate')?.toString().trim();
const reason = formData.get('reason')?.toString().trim() || null;
if (!leaveTypeId) return fail(400, { error: 'Leave type is required' });
if (!startDate) return fail(400, { error: 'Start date is required' });
if (!endDate) return fail(400, { error: 'End date is required' });
if (startDate > endDate) return fail(400, { error: 'Start date must be on or before end date' });
// For non-HR users, force employeeId to their own record
if (!isHrLike) {
const [myEmployee] = await db
.select({ id: employees.id })
.from(employees)
.where(
and(
eq(employees.companyId, params.companyId),
eq(employees.userId, user.id),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!myEmployee) {
return fail(400, { error: 'No employee record found for your account' });
}
employeeId = myEmployee.id;
}
if (!employeeId) return fail(400, { error: 'Employee is required' });
// Verify employee belongs to company
const [employee] = await db
.select({ id: employees.id, firstName: employees.firstName, lastName: employees.lastName, displayName: employees.displayName })
.from(employees)
.where(
and(
eq(employees.id, employeeId),
eq(employees.companyId, params.companyId),
isNull(employees.deletedAt)
)
)
.limit(1);
if (!employee) return fail(400, { error: 'Employee not found' });
// Verify leave type belongs to company
const [leaveType] = await db
.select({ id: leaveTypes.id, name: leaveTypes.name })
.from(leaveTypes)
.where(and(eq(leaveTypes.id, leaveTypeId), eq(leaveTypes.companyId, params.companyId)))
.limit(1);
if (!leaveType) return fail(400, { error: 'Leave type not found' });
// Fetch holiday dates for calculation
const holidays = await db
.select({ date: publicHolidays.date })
.from(publicHolidays)
.where(eq(publicHolidays.companyId, params.companyId));
const holidayDates = holidays.map((h) => h.date);
const days = calculateWorkingDays(startDate, endDate, holidayDates);
if (days <= 0) {
return fail(400, { error: 'The selected date range contains no working days' });
}
await db.insert(leaveRequests).values({
companyId: params.companyId,
employeeId,
leaveTypeId,
startDate,
endDate,
days: days.toFixed(2),
reason,
status: 'pending'
});
const employeeName = employee.displayName ?? `${employee.firstName} ${employee.lastName}`;
await logCompanyEvent(
params.companyId,
user.id,
'leave_submitted',
`Leave request submitted for ${employeeName}${leaveType.name}`,
{
employeeId,
leaveTypeId,
days,
type: leaveType.name,
period: `${startDate} to ${endDate}`
}
);
redirect(302, `/companies/${params.companyId}/hr/leave-requests`);
}
};
@@ -0,0 +1,219 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { calculateWorkingDays } from '$lib/utils/leave.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let startDate = $state('');
let endDate = $state('');
// svelte-ignore state_referenced_locally
let selectedEmployeeId = $state(data.isHrLike ? '' : (data.myEmployeeId ?? ''));
let selectedLeaveTypeId = $state('');
const workingDays = $derived(
startDate && endDate && startDate <= endDate
? calculateWorkingDays(startDate, endDate, data.holidayDates)
: null
);
// Find own employee for display name when not HR
const myEmployee = $derived(
data.isHrLike
? null
: data.employees.find((e: { id: string }) => e.id === data.myEmployeeId) ?? null
);
const balanceForSelection = $derived.by(() => {
const eid = data.isHrLike ? selectedEmployeeId : data.myEmployeeId;
if (!eid || !selectedLeaveTypeId) return null;
return data.balances?.[eid]?.[selectedLeaveTypeId] ?? null;
});
const selectedLeaveType = $derived(
data.leaveTypes.find((lt: { id: string }) => lt.id === selectedLeaveTypeId) ?? null
);
const wouldExceed = $derived(
balanceForSelection && workingDays !== null && workingDays > balanceForSelection.remaining
);
</script>
<svelte:head>
<title>New Leave Request - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-xl">
<div class="mb-4 flex items-center gap-3">
<a
href="/companies/{data.company.id}/hr/leave-requests"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
← Back
</a>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">New Leave Request</h2>
</div>
{#if data.noEmployeeRecord}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-6 dark:border-amber-700/50 dark:bg-amber-900/20">
<p class="text-sm text-amber-800 dark:text-amber-300">
Your account is not linked to an employee record in this company. Please ask HR to create your employee profile.
</p>
</div>
{:else}
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 space-y-5">
<!-- Employee -->
<div>
<label for="employeeId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Employee
</label>
{#if data.isHrLike}
<select
id="employeeId"
name="employeeId"
required
bind:value={selectedEmployeeId}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">Select employee…</option>
{#each data.employees as emp}
<option value={emp.id}>
{emp.displayName ?? `${emp.firstName} ${emp.lastName}`}
</option>
{/each}
</select>
{:else}
<input type="hidden" name="employeeId" value={data.myEmployeeId ?? ''} />
<p class="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700/50 dark:text-gray-300">
{myEmployee
? (myEmployee.displayName ?? `${myEmployee.firstName} ${myEmployee.lastName}`)
: '—'}
</p>
{/if}
</div>
<!-- Leave Type -->
<div>
<label for="leaveTypeId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Leave Type <span class="text-red-500">*</span>
</label>
{#if data.leaveTypes.length === 0}
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-3 text-sm text-amber-700 dark:text-amber-300">
No leave types configured for this company.
<a href="/companies/{data.company.id}/hr/leave-types" class="font-medium underline">
Add leave types
</a>
first.
</div>
{:else}
<select
id="leaveTypeId"
name="leaveTypeId"
required
bind:value={selectedLeaveTypeId}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">Select type…</option>
{#each data.leaveTypes as lt}
<option value={lt.id}>
{lt.name}{lt.isPaid ? '' : ' (Unpaid)'}
</option>
{/each}
</select>
{#if balanceForSelection && selectedLeaveType}
<div class="mt-2 rounded-md border {wouldExceed ? 'border-red-300 bg-red-50 dark:border-red-700/50 dark:bg-red-900/20' : 'border-blue-200 bg-blue-50 dark:border-blue-700/50 dark:bg-blue-900/20'} px-3 py-2 text-sm">
<div class="flex items-center justify-between {wouldExceed ? 'text-red-800 dark:text-red-300' : 'text-blue-800 dark:text-blue-300'}">
<span>
<strong>{balanceForSelection.remaining}</strong> day{balanceForSelection.remaining === 1 ? '' : 's'} remaining for {selectedLeaveType.name} in {data.leaveYear}
</span>
<span class="text-xs opacity-75">
{balanceForSelection.used} used / {balanceForSelection.allocated} allocated
</span>
</div>
{#if wouldExceed && workingDays !== null}
<p class="mt-1 text-xs text-red-700 dark:text-red-400">
⚠ This request ({workingDays} days) exceeds the remaining balance.
</p>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="startDate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Start Date <span class="text-red-500">*</span>
</label>
<input
type="date"
id="startDate"
name="startDate"
required
bind:value={startDate}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="endDate" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
End Date <span class="text-red-500">*</span>
</label>
<input
type="date"
id="endDate"
name="endDate"
required
bind:value={endDate}
min={startDate || undefined}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
{#if workingDays !== null}
<p class="text-sm text-gray-600 dark:text-gray-400">
Working days: <span class="font-semibold text-gray-900 dark:text-white">{workingDays}</span>
{#if workingDays === 0}
<span class="text-amber-600 dark:text-amber-400 ml-1">(No working days in range)</span>
{/if}
</p>
{/if}
<!-- Reason -->
<div>
<label for="reason" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reason <span class="text-xs text-gray-400">(optional)</span>
</label>
<textarea
id="reason"
name="reason"
rows="3"
placeholder="Brief reason for leave…"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-2">
<a
href="/companies/{data.company.id}/hr/leave-requests"
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Submit Request
</button>
</div>
</form>
{/if}
</div>
@@ -0,0 +1,106 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { leaveTypes } from '$lib/server/db/schema.js';
import { eq, and, asc } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin', 'manager']);
await parent();
const types = await db
.select()
.from(leaveTypes)
.where(eq(leaveTypes.companyId, params.companyId))
.orderBy(asc(leaveTypes.name));
return { leaveTypes: types };
};
export const actions: Actions = {
add: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const defaultDaysRaw = formData.get('defaultDaysPerYear')?.toString().trim();
const isPaid = formData.get('isPaid') === 'on';
const color = formData.get('color')?.toString().trim() || null;
if (!name) return fail(400, { error: 'Leave type name is required' });
const defaultDaysPerYear =
defaultDaysRaw && defaultDaysRaw !== '' && !isNaN(parseFloat(defaultDaysRaw))
? parseFloat(defaultDaysRaw).toFixed(2)
: null;
await db.insert(leaveTypes).values({
companyId: params.companyId,
name,
defaultDaysPerYear,
isPaid,
color
});
await logCompanyEvent(
params.companyId,
user.id,
'leave_type_created',
`Leave type "${name}" created`,
{ isPaid, defaultDaysPerYear }
);
return { success: true };
},
update: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const id = formData.get('id')?.toString();
const name = formData.get('name')?.toString().trim();
const defaultDaysRaw = formData.get('defaultDaysPerYear')?.toString().trim();
const isPaid = formData.get('isPaid') === 'on';
const color = formData.get('color')?.toString().trim() || null;
if (!id) return fail(400, { error: 'Missing leave type ID' });
if (!name) return fail(400, { error: 'Leave type name is required' });
const defaultDaysPerYear =
defaultDaysRaw && defaultDaysRaw !== '' && !isNaN(parseFloat(defaultDaysRaw))
? parseFloat(defaultDaysRaw).toFixed(2)
: null;
await db
.update(leaveTypes)
.set({ name, defaultDaysPerYear, isPaid, color })
.where(and(eq(leaveTypes.id, id), eq(leaveTypes.companyId, params.companyId)));
await logCompanyEvent(
params.companyId,
user.id,
'leave_type_created',
`Leave type "${name}" updated`,
{ leaveTypeId: id, defaultDaysPerYear, isPaid }
);
return { success: true };
},
delete: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Missing leave type ID' });
await db
.delete(leaveTypes)
.where(and(eq(leaveTypes.id, id), eq(leaveTypes.companyId, params.companyId)));
return { success: true };
}
};
@@ -0,0 +1,205 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('hr')
);
</script>
<svelte:head>
<title>Leave Types - {data.company.name}</title>
</svelte:head>
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Leave Types</h2>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if canManage}
<form method="POST" action="?/add" use:enhance class="mb-6 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Add Leave Type</h3>
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-40">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="e.g. Annual Leave"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div class="w-36">
<label for="defaultDaysPerYear" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Days / Year
</label>
<input
type="number"
id="defaultDaysPerYear"
name="defaultDaysPerYear"
min="0"
step="0.5"
placeholder="e.g. 10"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div class="w-20">
<label for="color" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<input
type="color"
id="color"
name="color"
value="#3B82F6"
class="h-10 w-full rounded-md border border-gray-300 dark:border-gray-600"
/>
</div>
<div class="flex items-center gap-2 pb-2">
<input
type="checkbox"
id="isPaid"
name="isPaid"
checked
class="h-4 w-4 rounded border-gray-300 text-blue-600 dark:border-gray-600"
/>
<label for="isPaid" class="text-sm text-gray-700 dark:text-gray-300">Paid leave</label>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add
</button>
</div>
</form>
{/if}
{#if data.leaveTypes.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No leave types defined yet.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Days / Year</th>
<th class="px-4 py-3 font-medium">Paid</th>
{#if canManage}
<th class="w-20 px-4 py-3 font-medium"></th>
{/if}
</tr>
</thead>
<tbody>
{#each data.leaveTypes as lt}
<tr class="border-t border-gray-100 dark:border-gray-700">
{#if canManage}
<!-- Editable row -->
<td class="px-4 py-3" colspan="3">
<form
method="POST"
action="?/update"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="flex flex-wrap items-center gap-3"
>
<input type="hidden" name="id" value={lt.id} />
<div class="flex items-center gap-2">
<input
type="color"
name="color"
value={lt.color ?? '#3B82F6'}
class="h-7 w-7 rounded border border-gray-300 dark:border-gray-600"
/>
<input
type="text"
name="name"
required
value={lt.name}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:bg-gray-700 dark:text-white w-48"
/>
</div>
<div class="flex items-center gap-1">
<input
type="number"
name="defaultDaysPerYear"
min="0"
step="0.5"
value={lt.defaultDaysPerYear ?? ''}
placeholder="—"
class="w-20 rounded-md border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">days/year</span>
</div>
<label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
name="isPaid"
checked={lt.isPaid}
class="h-4 w-4 rounded border-gray-300 text-blue-600 dark:border-gray-600"
/>
Paid
</label>
<button
type="submit"
class="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
</form>
</td>
<td class="px-4 py-3">
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={lt.id} />
<button
type="submit"
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</form>
</td>
{:else}
<td class="px-4 py-3">
<div class="flex items-center gap-2">
{#if lt.color}
<div class="h-3 w-3 rounded-full flex-shrink-0" style="background-color: {lt.color}"></div>
{/if}
<span class="font-medium text-gray-900 dark:text-white">{lt.name}</span>
</div>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">
{lt.defaultDaysPerYear ?? '—'}
</td>
<td class="px-4 py-3">
{#if lt.isPaid}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/40 dark:text-green-300">
Paid
</span>
{:else}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
Unpaid
</span>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,270 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
employees,
salaryHistory,
payslips,
payslipLineItems,
companies
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { calculatePayroll } from '$lib/server/payroll/thailand.js';
import { eq, and, isNull, lte, desc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
await parent();
const now = new Date();
const year = parseInt(url.searchParams.get('year') ?? String(now.getFullYear()), 10);
const month = parseInt(url.searchParams.get('month') ?? String(now.getMonth() + 1), 10);
// Last day of the selected month (month is 1-indexed; passing month directly gives last day)
const lastDayOfMonth = new Date(year, month, 0).toISOString().split('T')[0];
// Fetch all active employees for this company
const activeEmployees = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
position: employees.position,
department: employees.department
})
.from(employees)
.where(
and(
eq(employees.companyId, params.companyId),
eq(employees.isActive, true),
isNull(employees.deletedAt)
)
);
// For each employee, find their latest salary effective on or before last day of the month
const salaryMap: Record<string, { grossSalary: string; currency: string } | null> = {};
for (const emp of activeEmployees) {
const [row] = await db
.select({ grossSalary: salaryHistory.grossSalary, currency: salaryHistory.currency })
.from(salaryHistory)
.where(
and(
eq(salaryHistory.employeeId, emp.id),
lte(salaryHistory.effectiveFrom, lastDayOfMonth)
)
)
.orderBy(desc(salaryHistory.effectiveFrom))
.limit(1);
salaryMap[emp.id] = row ?? null;
}
// Fetch existing payslips for this period
const periodPayslips = await db
.select({
id: payslips.id,
employeeId: payslips.employeeId,
status: payslips.status,
netPay: payslips.netPay,
currency: payslips.currency
})
.from(payslips)
.where(
and(
eq(payslips.companyId, params.companyId),
eq(payslips.periodYear, String(year)),
eq(payslips.periodMonth, String(month))
)
);
const payslipByEmployee: Record<
string,
{ id: string; status: string; netPay: string; currency: string }
> = {};
for (const p of periodPayslips) {
payslipByEmployee[p.employeeId] = {
id: p.id,
status: p.status,
netPay: p.netPay,
currency: p.currency
};
}
const employeeRows = activeEmployees.map((emp) => ({
...emp,
salary: salaryMap[emp.id] ?? null,
payslip: payslipByEmployee[emp.id] ?? null
}));
return {
year,
month,
employees: employeeRows
};
};
export const actions: Actions = {
generate: async ({ locals, params, url, request }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const year = parseInt(formData.get('year')?.toString() ?? '', 10);
const month = parseInt(formData.get('month')?.toString() ?? '', 10);
if (!year || !month || month < 1 || month > 12) {
return fail(400, { error: 'Invalid period' });
}
const lastDayOfMonth = new Date(year, month, 0).toISOString().split('T')[0];
// Fetch company for currency
const [company] = await db
.select({ currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) return fail(404, { error: 'Company not found' });
// Fetch active employees
const activeEmployees = await db
.select({ id: employees.id, firstName: employees.firstName, lastName: employees.lastName })
.from(employees)
.where(
and(
eq(employees.companyId, params.companyId),
eq(employees.isActive, true),
isNull(employees.deletedAt)
)
);
// Find employees that already have a payslip this period
const existingPayslips = await db
.select({ employeeId: payslips.employeeId })
.from(payslips)
.where(
and(
eq(payslips.companyId, params.companyId),
eq(payslips.periodYear, String(year)),
eq(payslips.periodMonth, String(month))
)
);
const alreadyGenerated = new Set(existingPayslips.map((p) => p.employeeId));
let generated = 0;
for (const emp of activeEmployees) {
if (alreadyGenerated.has(emp.id)) continue;
// Find latest salary effective on or before last day of month
const [salaryRow] = await db
.select({ grossSalary: salaryHistory.grossSalary })
.from(salaryHistory)
.where(
and(
eq(salaryHistory.employeeId, emp.id),
lte(salaryHistory.effectiveFrom, lastDayOfMonth)
)
)
.orderBy(desc(salaryHistory.effectiveFrom))
.limit(1);
if (!salaryRow) continue; // skip employees with no salary history
const baseSalary = parseFloat(salaryRow.grossSalary);
const payroll = calculatePayroll(baseSalary, 0, 0);
const netPay = parseFloat(
(
baseSalary -
payroll.ssoEmployee -
payroll.incomeTax
).toFixed(2)
);
// Insert payslip
const [newPayslip] = await db
.insert(payslips)
.values({
companyId: params.companyId,
employeeId: emp.id,
periodYear: String(year),
periodMonth: String(month),
grossSalary: baseSalary.toFixed(2),
overtime: '0.00',
bonus: '0.00',
otherEarnings: '0.00',
ssoEmployee: payroll.ssoEmployee.toFixed(2),
ssoEmployer: payroll.ssoEmployer.toFixed(2),
incomeTax: payroll.incomeTax.toFixed(2),
otherDeductions: '0.00',
netPay: netPay.toFixed(2),
currency: company.currency,
status: 'draft',
generatedBy: user.id
})
.returning({ id: payslips.id });
// Insert statutory line items
await db.insert(payslipLineItems).values([
{
payslipId: newPayslip.id,
type: 'earning',
label: 'Base Salary',
amount: baseSalary.toFixed(2),
isStatutory: true
},
{
payslipId: newPayslip.id,
type: 'deduction',
label: 'Social Security (Employee)',
amount: payroll.ssoEmployee.toFixed(2),
isStatutory: true
},
{
payslipId: newPayslip.id,
type: 'deduction',
label: 'Income Tax (WHT)',
amount: payroll.incomeTax.toFixed(2),
isStatutory: true
}
]);
generated++;
}
if (generated > 0) {
await logCompanyEvent(
params.companyId,
user.id,
'payslip_generated',
`Generated ${generated} payslip${generated === 1 ? '' : 's'} for ${year}-${String(month).padStart(2, '0')}`,
{ year, month, count: generated }
);
}
return { success: true, generated };
},
delete: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const formData = await request.formData();
const payslipId = formData.get('payslipId')?.toString();
if (!payslipId) return fail(400, { error: 'Missing payslip ID' });
const [existing] = await db
.select({ id: payslips.id, status: payslips.status, employeeId: payslips.employeeId })
.from(payslips)
.where(and(eq(payslips.id, payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be deleted' });
await db.delete(payslips).where(eq(payslips.id, payslipId));
return { success: true };
}
};
@@ -0,0 +1,212 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 5 }, (_, i) => currentYear - 2 + i);
const allHavePayslips = $derived(
data.employees.length > 0 && data.employees.every((e) => e.payslip !== null)
);
const noSalaryEmployees = $derived(
data.employees.filter((e) => !e.payslip && !e.salary)
);
function statusBadgeClass(status: string) {
if (status === 'paid') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
if (status === 'finalized') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
if (status === 'draft') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
return 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
}
const fullName = (e: { displayName: string | null; firstName: string; lastName: string }) =>
e.displayName ?? `${e.firstName} ${e.lastName}`;
</script>
<svelte:head>
<title>Payroll - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Payroll</h2>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form?.success && form.generated !== undefined}
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
{#if (form.generated ?? 0) === 0}
No new payslips to generate — all employees already have payslips for this period.
{:else}
Generated {form.generated} payslip{form.generated === 1 ? '' : 's'} for {MONTHS[data.month - 1]} {data.year}.
{/if}
</div>
{/if}
<!-- Period picker -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Period:</span>
<div class="flex flex-wrap gap-1">
{#each years as y}
{#each MONTHS as mLabel, mIdx}
{@const m = mIdx + 1}
{@const isActive = y === data.year && m === data.month}
{/each}
{/each}
</div>
<!-- Year select -->
<div class="flex items-center gap-2">
<select
id="year-select"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
onchange={(e) => {
const target = e.currentTarget as HTMLSelectElement;
const url = new URL(window.location.href);
url.searchParams.set('year', target.value);
url.searchParams.set('month', String(data.month));
window.location.href = url.toString();
}}
>
{#each years as y}
<option value={y} selected={y === data.year}>{y}</option>
{/each}
</select>
<select
id="month-select"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
onchange={(e) => {
const target = e.currentTarget as HTMLSelectElement;
const url = new URL(window.location.href);
url.searchParams.set('year', String(data.year));
url.searchParams.set('month', target.value);
window.location.href = url.toString();
}}
>
{#each MONTHS as mLabel, mIdx}
<option value={mIdx + 1} selected={mIdx + 1 === data.month}>{mLabel}</option>
{/each}
</select>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{MONTHS[data.month - 1]} {data.year}
</span>
<!-- Generate button -->
<form method="POST" action="?/generate" use:enhance class="ml-auto">
<input type="hidden" name="year" value={data.year} />
<input type="hidden" name="month" value={data.month} />
<button
type="submit"
disabled={allHavePayslips}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Generate Payslips
</button>
</form>
</div>
{#if noSalaryEmployees.length > 0}
<div class="mb-4 rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{noSalaryEmployees.length} employee{noSalaryEmployees.length === 1 ? '' : 's'} will be skipped (no salary history as of this period):
{noSalaryEmployees.map((e) => fullName(e)).join(', ')}.
</div>
{/if}
{#if data.employees.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No active employees found.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Employee</th>
<th class="px-4 py-3 font-medium">Department / Position</th>
<th class="px-4 py-3 font-medium">Current Salary</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium text-right">Net Pay</th>
<th class="px-4 py-3 font-medium w-32"></th>
</tr>
</thead>
<tbody>
{#each data.employees as emp}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{fullName(emp)}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">
{[emp.position, emp.department].filter(Boolean).join(' · ') || '—'}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{#if emp.salary}
{formatCurrency(emp.salary.grossSalary, emp.salary.currency)}
{:else}
<span class="text-gray-400 dark:text-gray-500 italic">No salary</span>
{/if}
</td>
<td class="px-4 py-3">
{#if emp.payslip}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(emp.payslip.status)}">
{emp.payslip.status.charAt(0).toUpperCase() + emp.payslip.status.slice(1)}
</span>
{:else}
<span class="rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500">
Not generated
</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{#if emp.payslip}
{formatCurrency(emp.payslip.netPay, emp.payslip.currency)}
{:else}
<span class="text-gray-400 dark:text-gray-500"></span>
{/if}
</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
{#if emp.payslip}
<a
href="/companies/{data.company.id}/hr/payroll/{emp.payslip.id}"
class="rounded px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
View
</a>
{#if emp.payslip.status === 'draft'}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="payslipId" value={emp.payslip.id} />
<button
type="submit"
onclick={(e) => { if (!confirm('Delete this draft payslip?')) e.preventDefault(); }}
class="rounded px-2 py-1 text-xs font-medium bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:hover:bg-red-900/60"
>
Delete
</button>
</form>
{/if}
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,280 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
payslips,
payslipLineItems,
employees,
companies
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { eq, and, asc } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
await parent();
const [payslip] = await db
.select()
.from(payslips)
.where(
and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId))
)
.limit(1);
if (!payslip) error(404, 'Payslip not found');
const [employee] = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
displayName: employees.displayName,
position: employees.position,
department: employees.department,
employeeCode: employees.employeeCode,
nationalId: employees.nationalId,
bankName: employees.bankName,
bankAccount: employees.bankAccount
})
.from(employees)
.where(eq(employees.id, payslip.employeeId))
.limit(1);
if (!employee) error(404, 'Employee not found');
const [company] = await db
.select({ id: companies.id, name: companies.name, currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) error(404, 'Company not found');
const lineItems = await db
.select()
.from(payslipLineItems)
.where(eq(payslipLineItems.payslipId, params.payslipId))
.orderBy(asc(payslipLineItems.type));
return { payslip, employee, company, lineItems };
};
export const actions: Actions = {
updatePayslip: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status, grossSalary: payslips.grossSalary })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be edited' });
const formData = await request.formData();
const overtime = parseFloat(formData.get('overtime')?.toString() ?? '0') || 0;
const bonus = parseFloat(formData.get('bonus')?.toString() ?? '0') || 0;
const otherEarnings = parseFloat(formData.get('otherEarnings')?.toString() ?? '0') || 0;
const otherDeductions = parseFloat(formData.get('otherDeductions')?.toString() ?? '0') || 0;
const ssoEmployee = parseFloat(formData.get('ssoEmployee')?.toString() ?? '0') || 0;
const incomeTax = parseFloat(formData.get('incomeTax')?.toString() ?? '0') || 0;
const grossSalary = parseFloat(existing.grossSalary);
const netPay = parseFloat(
(grossSalary + overtime + bonus + otherEarnings - ssoEmployee - incomeTax - otherDeductions).toFixed(2)
);
await db
.update(payslips)
.set({
overtime: overtime.toFixed(2),
bonus: bonus.toFixed(2),
otherEarnings: otherEarnings.toFixed(2),
otherDeductions: otherDeductions.toFixed(2),
ssoEmployee: ssoEmployee.toFixed(2),
incomeTax: incomeTax.toFixed(2),
netPay: netPay.toFixed(2),
updatedAt: new Date()
})
.where(eq(payslips.id, params.payslipId));
return { success: true, action: 'updatePayslip' };
},
addLineItem: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be edited' });
const formData = await request.formData();
const type = formData.get('type')?.toString();
const label = formData.get('label')?.toString().trim();
const amountRaw = formData.get('amount')?.toString();
if (!type || (type !== 'earning' && type !== 'deduction')) {
return fail(400, { error: 'Invalid line item type' });
}
if (!label) return fail(400, { error: 'Label is required' });
const amount = parseFloat(amountRaw ?? '');
if (isNaN(amount) || amount < 0) return fail(400, { error: 'Valid amount is required' });
await db.insert(payslipLineItems).values({
payslipId: params.payslipId,
type: type as 'earning' | 'deduction',
label,
amount: amount.toFixed(2),
isStatutory: false
});
return { success: true, action: 'addLineItem' };
},
updateLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be edited' });
const formData = await request.formData();
const lineItemId = formData.get('lineItemId')?.toString();
const label = formData.get('label')?.toString().trim();
const amountRaw = formData.get('amount')?.toString();
if (!lineItemId) return fail(400, { error: 'Missing line item ID' });
const amount = parseFloat(amountRaw ?? '');
if (isNaN(amount) || amount < 0) return fail(400, { error: 'Valid amount is required' });
const updates: Partial<{ label: string; amount: string }> = {
amount: amount.toFixed(2)
};
if (label) updates.label = label;
await db
.update(payslipLineItems)
.set(updates)
.where(
and(
eq(payslipLineItems.id, lineItemId),
eq(payslipLineItems.payslipId, params.payslipId)
)
);
return { success: true, action: 'updateLineItem' };
},
removeLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be edited' });
const formData = await request.formData();
const lineItemId = formData.get('lineItemId')?.toString();
if (!lineItemId) return fail(400, { error: 'Missing line item ID' });
// Prevent removing statutory items
const [item] = await db
.select({ isStatutory: payslipLineItems.isStatutory })
.from(payslipLineItems)
.where(
and(
eq(payslipLineItems.id, lineItemId),
eq(payslipLineItems.payslipId, params.payslipId)
)
)
.limit(1);
if (!item) return fail(404, { error: 'Line item not found' });
if (item.isStatutory) return fail(400, { error: 'Statutory line items cannot be removed' });
await db
.delete(payslipLineItems)
.where(
and(
eq(payslipLineItems.id, lineItemId),
eq(payslipLineItems.payslipId, params.payslipId)
)
);
return { success: true, action: 'removeLineItem' };
},
finalize: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status, employeeId: payslips.employeeId })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'draft') return fail(400, { error: 'Only draft payslips can be finalized' });
await db
.update(payslips)
.set({ status: 'finalized', finalizedAt: new Date(), updatedAt: new Date() })
.where(eq(payslips.id, params.payslipId));
await logCompanyEvent(
params.companyId,
user.id,
'payslip_finalized',
`Payslip finalized`,
{ payslipId: params.payslipId, employeeId: existing.employeeId }
);
return { success: true, action: 'finalize' };
},
markPaid: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [existing] = await db
.select({ id: payslips.id, status: payslips.status, employeeId: payslips.employeeId })
.from(payslips)
.where(and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId)))
.limit(1);
if (!existing) return fail(404, { error: 'Payslip not found' });
if (existing.status !== 'finalized') return fail(400, { error: 'Only finalized payslips can be marked as paid' });
await db
.update(payslips)
.set({ status: 'paid', paidAt: new Date(), updatedAt: new Date() })
.where(eq(payslips.id, params.payslipId));
await logCompanyEvent(
params.companyId,
user.id,
'payslip_paid',
`Payslip marked as paid`,
{ payslipId: params.payslipId, employeeId: existing.employeeId }
);
return { success: true, action: 'markPaid' };
}
};
@@ -0,0 +1,458 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const payslip = $derived(data.payslip);
const employee = $derived(data.employee);
const company = $derived(data.company);
const isDraft = $derived(payslip.status === 'draft');
const isFinalized = $derived(payslip.status === 'finalized');
const fullName = $derived(
employee.displayName ?? `${employee.firstName} ${employee.lastName}`
);
const periodLabel = $derived(
`${MONTHS[Number(payslip.periodMonth) - 1]} ${payslip.periodYear}`
);
const earningLineItems = $derived(
data.lineItems.filter((li) => li.type === 'earning' && !li.isStatutory)
);
const deductionLineItems = $derived(
data.lineItems.filter((li) => li.type === 'deduction' && !li.isStatutory)
);
const grossSalary = $derived(parseFloat(payslip.grossSalary));
const overtime = $derived(parseFloat(payslip.overtime));
const bonus = $derived(parseFloat(payslip.bonus));
const otherEarnings = $derived(parseFloat(payslip.otherEarnings));
const ssoEmployee = $derived(parseFloat(payslip.ssoEmployee));
const incomeTax = $derived(parseFloat(payslip.incomeTax));
const otherDeductions = $derived(parseFloat(payslip.otherDeductions));
const netPay = $derived(parseFloat(payslip.netPay));
function statusBadgeClass(status: string) {
if (status === 'paid') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
if (status === 'finalized') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
}
let showAddLineItem = $state(false);
let addLineType: 'earning' | 'deduction' = $state('earning');
</script>
<svelte:head>
<title>Payslip {fullName} {periodLabel} - {company.name}</title>
</svelte:head>
<!-- Feedback banners -->
{#if form?.success}
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
{#if form.action === 'finalize'}Payslip finalized successfully.{/if}
{#if form.action === 'markPaid'}Payslip marked as paid.{/if}
{#if form.action === 'updatePayslip'}Payslip amounts saved.{/if}
{#if form.action === 'addLineItem'}Line item added.{/if}
{#if form.action === 'updateLineItem'}Line item updated.{/if}
{#if form.action === 'removeLineItem'}Line item removed.{/if}
</div>
{/if}
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- Header -->
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<div class="mb-1 flex items-center gap-3">
<a
href="/companies/{company.id}/hr/payroll?year={payslip.periodYear}&month={payslip.periodMonth}"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
&larr; Payroll
</a>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{fullName}{periodLabel}
</h2>
{#if employee.position || employee.department}
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{[employee.position, employee.department].filter(Boolean).join(' · ')}
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<span class="rounded-full px-3 py-1 text-xs font-medium {statusBadgeClass(payslip.status)}">
{payslip.status.charAt(0).toUpperCase() + payslip.status.slice(1)}
</span>
<a
href="/companies/{company.id}/hr/payroll/{payslip.id}/pdf"
target="_blank"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Download PDF
</a>
{#if isDraft}
<form method="POST" action="?/finalize" use:enhance>
<button
type="submit"
onclick={(e) => { if (!confirm('Finalize this payslip? It will become read-only.')) e.preventDefault(); }}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Finalize
</button>
</form>
{/if}
{#if isFinalized}
<form method="POST" action="?/markPaid" use:enhance>
<button
type="submit"
onclick={(e) => { if (!confirm('Mark this payslip as paid?')) e.preventDefault(); }}
class="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
>
Mark Paid
</button>
</form>
{/if}
</div>
</div>
<!-- Main payslip card -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<!-- Earnings & Deductions edit form (draft only) -->
{#if isDraft}
<form method="POST" action="?/updatePayslip" use:enhance class="border-b border-gray-100 p-6 dark:border-gray-700">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Earnings -->
<div>
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">Earnings</h3>
<div class="space-y-3">
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2.5 dark:bg-gray-700/50">
<span class="text-sm text-gray-600 dark:text-gray-400">Base Salary (read-only)</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(grossSalary, company.currency)}
</span>
</div>
<div>
<label for="overtime" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">Overtime</label>
<input
type="number"
id="overtime"
name="overtime"
min="0"
step="0.01"
value={overtime}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="bonus" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">Bonus</label>
<input
type="number"
id="bonus"
name="bonus"
min="0"
step="0.01"
value={bonus}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="otherEarnings" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">Other Earnings</label>
<input
type="number"
id="otherEarnings"
name="otherEarnings"
min="0"
step="0.01"
value={otherEarnings}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
</div>
<!-- Deductions -->
<div>
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">Deductions</h3>
<div class="space-y-3">
<div>
<label for="ssoEmployee" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">
Social Security (Employee)
</label>
<input
type="number"
id="ssoEmployee"
name="ssoEmployee"
min="0"
step="0.01"
value={ssoEmployee}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="incomeTax" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">
Income Tax (WHT)
</label>
<input
type="number"
id="incomeTax"
name="incomeTax"
min="0"
step="0.01"
value={incomeTax}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="otherDeductions" class="mb-1 block text-sm text-gray-600 dark:text-gray-400">
Other Deductions
</label>
<input
type="number"
id="otherDeductions"
name="otherDeductions"
min="0"
step="0.01"
value={otherDeductions}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Changes
</button>
</div>
</form>
{:else}
<!-- Read-only view for finalized/paid -->
<div class="border-b border-gray-100 p-6 dark:border-gray-700">
<div class="grid gap-6 lg:grid-cols-2">
<div>
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">Earnings</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Base Salary</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(grossSalary, company.currency)}</span>
</div>
{#if overtime > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Overtime</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(overtime, company.currency)}</span>
</div>
{/if}
{#if bonus > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Bonus</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(bonus, company.currency)}</span>
</div>
{/if}
{#if otherEarnings > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Other Earnings</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(otherEarnings, company.currency)}</span>
</div>
{/if}
{#each earningLineItems as li}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{li.label}</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(li.amount, company.currency)}</span>
</div>
{/each}
</div>
</div>
<div>
<h3 class="mb-4 font-semibold text-gray-900 dark:text-white">Deductions</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Social Security (Employee)</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(ssoEmployee, company.currency)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Income Tax (WHT)</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(incomeTax, company.currency)}</span>
</div>
{#if otherDeductions > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Other Deductions</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(otherDeductions, company.currency)}</span>
</div>
{/if}
{#each deductionLineItems as li}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{li.label}</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(li.amount, company.currency)}</span>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<!-- Custom line items section -->
{#if isDraft || earningLineItems.length > 0 || deductionLineItems.length > 0}
<div class="border-b border-gray-100 p-6 dark:border-gray-700">
<div class="mb-3 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Custom Line Items</h3>
{#if isDraft}
<button
type="button"
onclick={() => (showAddLineItem = !showAddLineItem)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
{showAddLineItem ? 'Cancel' : 'Add Line Item'}
</button>
{/if}
</div>
{#if showAddLineItem && isDraft}
<form method="POST" action="?/addLineItem" use:enhance={() => {
return ({ result, update }) => {
if (result.type === 'success') showAddLineItem = false;
update();
};
}} class="mb-4 rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-700/40">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label for="add-type" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
<select
id="add-type"
name="type"
bind:value={addLineType}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="earning">Earning</option>
<option value="deduction">Deduction</option>
</select>
</div>
<div>
<label for="add-label" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Label</label>
<input
type="text"
id="add-label"
name="label"
required
placeholder="e.g. Transport Allowance"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label for="add-amount" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Amount</label>
<input
type="number"
id="add-amount"
name="amount"
required
min="0"
step="0.01"
placeholder="0.00"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Add
</button>
</div>
</form>
{/if}
{#if earningLineItems.length === 0 && deductionLineItems.length === 0}
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No custom line items.</p>
{:else}
<div class="space-y-1">
{#each [...earningLineItems, ...deductionLineItems] as li}
<div class="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<div class="flex items-center gap-2 text-sm">
<span class="rounded px-1.5 py-0.5 text-xs font-medium {li.type === 'earning' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400'}">
{li.type === 'earning' ? 'Earning' : 'Deduction'}
</span>
<span class="text-gray-700 dark:text-gray-300">{li.label}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(li.amount, company.currency)}
</span>
{#if isDraft}
<form method="POST" action="?/removeLineItem" use:enhance>
<input type="hidden" name="lineItemId" value={li.id} />
<button
type="submit"
class="text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
aria-label="Remove line item"
>
Remove
</button>
</form>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Net Pay summary -->
<div class="p-6">
<div class="rounded-lg border-2 border-gray-300 bg-gray-50 px-6 py-4 dark:border-gray-600 dark:bg-gray-700/50">
<div class="flex items-center justify-between">
<span class="text-base font-semibold text-gray-700 dark:text-gray-300">NET PAY</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(netPay, company.currency)}
</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-4 text-xs text-gray-500 dark:text-gray-400 sm:grid-cols-4">
<div>
<span class="block">Gross Salary</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{formatCurrency(grossSalary, company.currency)}</span>
</div>
<div>
<span class="block">SSO (Employer)</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{formatCurrency(payslip.ssoEmployer, company.currency)}</span>
</div>
{#if payslip.finalizedAt}
<div>
<span class="block">Finalized</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{new Date(payslip.finalizedAt).toLocaleDateString()}
</span>
</div>
{/if}
{#if payslip.paidAt}
<div>
<span class="block">Paid</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
{new Date(payslip.paidAt).toLocaleDateString()}
</span>
</div>
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,128 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import {
payslips,
payslipLineItems,
employees,
companies
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { generatePayslipPDF } from '$lib/server/payroll/pdf.js';
import { eq, and, asc } from 'drizzle-orm';
export const GET: RequestHandler = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['hr', 'admin']);
const [payslip] = await db
.select()
.from(payslips)
.where(
and(eq(payslips.id, params.payslipId), eq(payslips.companyId, params.companyId))
)
.limit(1);
if (!payslip) error(404, 'Payslip not found');
const [employee] = await db
.select({
id: employees.id,
firstName: employees.firstName,
lastName: employees.lastName,
employeeCode: employees.employeeCode,
position: employees.position,
department: employees.department,
nationalId: employees.nationalId,
bankName: employees.bankName,
bankAccount: employees.bankAccount
})
.from(employees)
.where(eq(employees.id, payslip.employeeId))
.limit(1);
if (!employee) error(404, 'Employee not found');
const [company] = await db
.select({ name: companies.name, currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) error(404, 'Company not found');
const lineItems = await db
.select()
.from(payslipLineItems)
.where(eq(payslipLineItems.payslipId, params.payslipId))
.orderBy(asc(payslipLineItems.type));
const grossSalary = parseFloat(payslip.grossSalary);
const overtime = parseFloat(payslip.overtime);
const bonus = parseFloat(payslip.bonus);
const otherEarnings = parseFloat(payslip.otherEarnings);
const ssoEmployee = parseFloat(payslip.ssoEmployee);
const incomeTax = parseFloat(payslip.incomeTax);
const otherDeductions = parseFloat(payslip.otherDeductions);
const netPay = parseFloat(payslip.netPay);
// Build earnings list
const earnings: { label: string; amount: number }[] = [
{ label: 'Base Salary', amount: grossSalary }
];
if (overtime > 0) earnings.push({ label: 'Overtime', amount: overtime });
if (bonus > 0) earnings.push({ label: 'Bonus', amount: bonus });
if (otherEarnings > 0) earnings.push({ label: 'Other Earnings', amount: otherEarnings });
// Non-statutory earning line items
for (const li of lineItems.filter((l) => l.type === 'earning' && !l.isStatutory)) {
earnings.push({ label: li.label, amount: parseFloat(li.amount) });
}
// Build deductions list
const deductions: { label: string; amount: number }[] = [
{ label: 'Social Security (Employee)', amount: ssoEmployee },
{ label: 'Income Tax (WHT)', amount: incomeTax }
];
if (otherDeductions > 0) deductions.push({ label: 'Other Deductions', amount: otherDeductions });
// Non-statutory deduction line items
for (const li of lineItems.filter((l) => l.type === 'deduction' && !l.isStatutory)) {
deductions.push({ label: li.label, amount: parseFloat(li.amount) });
}
const grossTotal = earnings.reduce((sum, e) => sum + e.amount, 0);
const deductionTotal = deductions.reduce((sum, d) => sum + d.amount, 0);
const year = Number(payslip.periodYear);
const month = Number(payslip.periodMonth);
const pdfBytes = await generatePayslipPDF({
company: { name: company.name, currency: company.currency },
employee: {
firstName: employee.firstName,
lastName: employee.lastName,
employeeCode: employee.employeeCode,
position: employee.position,
department: employee.department,
nationalId: employee.nationalId,
bankName: employee.bankName,
bankAccount: employee.bankAccount
},
period: { year, month },
earnings,
deductions,
grossTotal,
deductionTotal,
netPay,
generatedAt: new Date()
});
const filename = `payslip-${employee.lastName}-${year}-${String(month).padStart(2, '0')}.pdf`;
return new Response(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
};
@@ -0,0 +1,233 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js';
import { eq, and, isNull, sql, desc } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import * as kasikorn from '$lib/server/integrations/kasikorn.js';
import * as etherfi from '$lib/server/integrations/etherfi.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
// Fetch all external accounts for this company with tx counts
const accounts = await db
.select({
id: externalAccounts.id,
provider: externalAccounts.provider,
displayName: externalAccounts.displayName,
accountIdentifier: externalAccounts.accountIdentifier,
isActive: externalAccounts.isActive,
lastSyncedAt: externalAccounts.lastSyncedAt,
createdAt: externalAccounts.createdAt,
txCount: sql<number>`cast(count(${externalTransactions.id}) as int)`,
unmatchedCount: sql<number>`cast(sum(case when ${externalTransactions.matchedExpenseId} is null then 1 else 0 end) as int)`
})
.from(externalAccounts)
.leftJoin(externalTransactions, eq(externalTransactions.accountId, externalAccounts.id))
.where(eq(externalAccounts.companyId, params.companyId))
.groupBy(externalAccounts.id)
.orderBy(externalAccounts.createdAt);
// Fetch 50 most recent transactions with account name
const recentTransactions = await db
.select({
id: externalTransactions.id,
accountId: externalTransactions.accountId,
accountName: externalAccounts.displayName,
accountProvider: externalAccounts.provider,
occurredAt: externalTransactions.occurredAt,
amount: externalTransactions.amount,
currency: externalTransactions.currency,
direction: externalTransactions.direction,
description: externalTransactions.description,
counterparty: externalTransactions.counterparty,
matchedExpenseId: externalTransactions.matchedExpenseId
})
.from(externalTransactions)
.innerJoin(externalAccounts, eq(externalTransactions.accountId, externalAccounts.id))
.where(eq(externalTransactions.companyId, params.companyId))
.orderBy(desc(externalTransactions.occurredAt))
.limit(50);
// Fetch unresolved (pending) expenses for matching dropdown
const matchableExpenses = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
currency: expenses.currency,
expenseDate: expenses.expenseDate,
status: expenses.status
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(
eq(projects.companyId, params.companyId),
eq(expenses.status, 'pending')
)
)
.orderBy(desc(expenses.createdAt))
.limit(200);
return {
accounts,
recentTransactions,
matchableExpenses
};
};
export const actions: Actions = {
addAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const provider = formData.get('provider')?.toString() as 'kasikorn_kbiz' | 'etherfi' | 'manual' | undefined;
const displayName = formData.get('displayName')?.toString().trim();
const accountIdentifier = formData.get('accountIdentifier')?.toString().trim();
const credentialsJson = formData.get('credentialsJson')?.toString().trim() || null;
if (!provider) return fail(400, { error: 'Provider is required' });
if (!displayName) return fail(400, { error: 'Display name is required' });
if (!accountIdentifier) return fail(400, { error: 'Account identifier is required' });
const [account] = await db
.insert(externalAccounts)
.values({
companyId: params.companyId,
provider,
displayName,
accountIdentifier,
credentialsEncrypted: credentialsJson
})
.returning({ id: externalAccounts.id });
await logCompanyEvent(
params.companyId,
user.id,
'integration_connected',
`Connected ${provider} account "${displayName}" (${accountIdentifier})`,
{ accountId: account.id, provider, displayName }
);
return { success: true };
},
removeAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const accountId = formData.get('accountId')?.toString();
if (!accountId) return fail(400, { error: 'Account ID is required' });
const [account] = await db
.select({ displayName: externalAccounts.displayName, provider: externalAccounts.provider })
.from(externalAccounts)
.where(and(eq(externalAccounts.id, accountId), eq(externalAccounts.companyId, params.companyId)))
.limit(1);
if (!account) return fail(404, { error: 'Account not found' });
await db
.delete(externalAccounts)
.where(and(eq(externalAccounts.id, accountId), eq(externalAccounts.companyId, params.companyId)));
await logCompanyEvent(
params.companyId,
user.id,
'integration_disconnected',
`Removed ${account.provider} account "${account.displayName}"`,
{ accountId, provider: account.provider }
);
return { success: true };
},
syncNow: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const accountId = formData.get('accountId')?.toString();
if (!accountId) return fail(400, { error: 'Account ID is required' });
const [account] = await db
.select({
id: externalAccounts.id,
provider: externalAccounts.provider,
displayName: externalAccounts.displayName,
credentialsEncrypted: externalAccounts.credentialsEncrypted
})
.from(externalAccounts)
.where(and(eq(externalAccounts.id, accountId), eq(externalAccounts.companyId, params.companyId)))
.limit(1);
if (!account) return fail(404, { error: 'Account not found' });
const from = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
const to = new Date();
const credentials = account.credentialsEncrypted
? JSON.parse(account.credentialsEncrypted)
: {};
try {
if (account.provider === 'kasikorn_kbiz') {
await kasikorn.fetchTransactions(credentials, from, to);
} else if (account.provider === 'etherfi') {
await etherfi.fetchTransactions(credentials, from, to);
}
// manual provider: no-op sync
await db
.update(externalAccounts)
.set({ lastSyncedAt: new Date(), updatedAt: new Date() })
.where(eq(externalAccounts.id, accountId));
return { success: true, message: 'Sync complete' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Still update lastSyncedAt to record the attempt
await db
.update(externalAccounts)
.set({ lastSyncedAt: new Date(), updatedAt: new Date() })
.where(eq(externalAccounts.id, accountId));
return fail(500, { error: message });
}
},
matchExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
const expenseId = formData.get('expenseId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
if (!expenseId) return fail(400, { error: 'Expense ID is required' });
// Verify tx belongs to this company
const [tx] = await db
.select({ id: externalTransactions.id, amount: externalTransactions.amount })
.from(externalTransactions)
.where(and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId)))
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
await db
.update(externalTransactions)
.set({ matchedExpenseId: expenseId })
.where(eq(externalTransactions.id, txId));
await logCompanyEvent(
params.companyId,
user.id,
'transaction_matched',
`Matched external transaction to expense`,
{ txId, expenseId }
);
return { success: true };
}
};
@@ -0,0 +1,359 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
let showAddModal = $state(false);
let syncResults: Record<string, { error?: string; success?: boolean }> = $state({});
const providerLabels: Record<string, string> = {
kasikorn_kbiz: 'Kasikorn K-Biz',
etherfi: 'Ether.fi',
manual: 'Manual'
};
const providerColors: Record<string, string> = {
kasikorn_kbiz: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
etherfi: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
manual: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
};
function maskIdentifier(identifier: string): string {
if (identifier.length <= 4) return identifier;
return '****' + identifier.slice(-4);
}
function formatDate(date: Date | string | null): string {
if (!date) return '—';
return new Date(date).toLocaleString();
}
function formatAmount(amount: string, currency: string): string {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(Number(amount));
}
</script>
<svelte:head>
<title>Integrations - {data.company.name}</title>
</svelte:head>
<!-- System Admin / Experimental Banner -->
<div class="mb-5 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
<strong>System Admin Only — Experimental.</strong> External integrations are stubs and do not yet fetch real data. Sync will return an error message from the provider stub.
</div>
{#if form?.error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form?.success && form?.message}
<div class="mb-4 rounded-md border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/30 px-4 py-3 text-sm text-green-700 dark:text-green-300">
{form.message}
</div>
{/if}
<!-- Header -->
<div class="mb-5 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
External Accounts
<span class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">({data.accounts.length})</span>
</h2>
<button
type="button"
onclick={() => (showAddModal = true)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Add External Account
</button>
</div>
<!-- Account Cards -->
{#if data.accounts.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No external accounts configured.</p>
<button
type="button"
onclick={() => (showAddModal = true)}
class="mt-3 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Add your first account
</button>
</div>
{:else}
<div class="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.accounts as account}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div class="mb-3 flex items-start justify-between gap-2">
<div>
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {providerColors[account.provider]}">
{providerLabels[account.provider] ?? account.provider}
</span>
<p class="mt-1 font-medium text-gray-900 dark:text-white">{account.displayName}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{maskIdentifier(account.accountIdentifier)}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-gray-900 dark:text-white">{account.txCount ?? 0} txs</p>
{#if (account.unmatchedCount ?? 0) > 0}
<p class="text-xs text-amber-600 dark:text-amber-400">{account.unmatchedCount} unmatched</p>
{/if}
</div>
</div>
<p class="mb-3 text-xs text-gray-400 dark:text-gray-500">
Last synced: {formatDate(account.lastSyncedAt)}
</p>
{#if syncResults[account.id]?.error}
<div class="mb-3 rounded bg-red-50 dark:bg-red-900/30 px-2 py-1.5 text-xs text-red-700 dark:text-red-300">
{syncResults[account.id].error}
</div>
{/if}
{#if syncResults[account.id]?.success}
<div class="mb-3 rounded bg-green-50 dark:bg-green-900/30 px-2 py-1.5 text-xs text-green-700 dark:text-green-300">
Sync complete
</div>
{/if}
<div class="flex gap-2">
<form
method="POST"
action="?/syncNow"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
syncResults[account.id] = { error: (result.data as any)?.error ?? 'Sync failed' };
} else if (result.type === 'success') {
syncResults[account.id] = { success: true };
}
await update({ reset: false });
};
}}
>
<input type="hidden" name="accountId" value={account.id} />
<button
type="submit"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
>
Sync Now
</button>
</form>
<form method="POST" action="?/removeAccount" use:enhance>
<input type="hidden" name="accountId" value={account.id} />
<button
type="submit"
onclick={(e) => {
if (!confirm(`Remove account "${account.displayName}"? This will delete all associated transactions.`)) {
e.preventDefault();
}
}}
class="rounded-md border border-red-200 dark:border-red-700 bg-white dark:bg-gray-700 px-2.5 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30"
>
Remove
</button>
</form>
</div>
</div>
{/each}
</div>
{/if}
<!-- Recent Transactions Table -->
<div class="mb-2 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Transactions</h2>
<a
href="./integrations/transactions"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View all
</a>
</div>
{#if data.recentTransactions.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No transactions yet. Sync an account or add transactions manually.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Date</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Account</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Dir</th>
<th class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">Amount</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched</th>
</tr>
</thead>
<tbody>
{#each data.recentTransactions as tx}
<tr class="border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{new Date(tx.occurredAt).toLocaleDateString()}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
<span class="text-xs">{tx.accountName}</span>
</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{tx.direction === 'credit'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'}">
{tx.direction}
</span>
</td>
<td class="px-4 py-3 text-right font-mono text-gray-900 dark:text-white whitespace-nowrap">
{formatAmount(tx.amount, tx.currency)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-[160px] truncate">
{tx.description ?? '—'}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-[120px] truncate">
{tx.counterparty ?? '—'}
</td>
<td class="px-4 py-3">
{#if tx.matchedExpenseId}
<span class="text-xs text-green-600 dark:text-green-400">Matched</span>
{:else}
<form method="POST" action="?/matchExpense" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<select
name="expenseId"
onchange={(e) => {
if ((e.target as HTMLSelectElement).value) {
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
}
}}
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
>
<option value="">Match</option>
{#each data.matchableExpenses as exp}
<option value={exp.id}>{exp.title} ({exp.currency} {exp.amount})</option>
{/each}
</select>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Add Account Modal -->
{#if showAddModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Add External Account"
>
<div class="w-full max-w-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-xl">
<div class="mb-4 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white">Add External Account</h3>
<button
type="button"
onclick={() => (showAddModal = false)}
class="rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 p-1"
aria-label="Close"
>
&#x2715;
</button>
</div>
<form
method="POST"
action="?/addAccount"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') showAddModal = false;
};
}}
class="space-y-4"
>
<div>
<label for="provider" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Provider
</label>
<select
id="provider"
name="provider"
required
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
>
<option value="">Select provider</option>
<option value="kasikorn_kbiz">Kasikorn K-Biz</option>
<option value="etherfi">Ether.fi</option>
<option value="manual">Manual</option>
</select>
</div>
<div>
<label for="displayName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Display Name
</label>
<input
type="text"
id="displayName"
name="displayName"
required
placeholder="e.g. Main KBank Account"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
/>
</div>
<div>
<label for="accountIdentifier" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Account Identifier
</label>
<input
type="text"
id="accountIdentifier"
name="accountIdentifier"
required
placeholder="Account number or wallet address"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
/>
</div>
<div>
<label for="credentialsJson" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Credentials (JSON)
</label>
<textarea
id="credentialsJson"
name="credentialsJson"
rows="4"
placeholder={'{\n "clientId": "...",\n "clientSecret": "..."\n}'}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm font-mono"
></textarea>
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
Warning: credentials are stored as plaintext until encryption is implemented.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
onclick={() => (showAddModal = false)}
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Account
</button>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,149 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js';
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const matched = url.searchParams.get('matched') ?? 'all'; // 'yes' | 'no' | 'all'
// Build filter condition
let matchFilter = undefined;
if (matched === 'yes') {
matchFilter = isNotNull(externalTransactions.matchedExpenseId);
} else if (matched === 'no') {
matchFilter = isNull(externalTransactions.matchedExpenseId);
}
const transactions = await db
.select({
id: externalTransactions.id,
accountId: externalTransactions.accountId,
accountName: externalAccounts.displayName,
accountProvider: externalAccounts.provider,
externalId: externalTransactions.externalId,
occurredAt: externalTransactions.occurredAt,
amount: externalTransactions.amount,
currency: externalTransactions.currency,
direction: externalTransactions.direction,
description: externalTransactions.description,
counterparty: externalTransactions.counterparty,
matchedExpenseId: externalTransactions.matchedExpenseId,
createdAt: externalTransactions.createdAt
})
.from(externalTransactions)
.innerJoin(externalAccounts, eq(externalTransactions.accountId, externalAccounts.id))
.where(
matchFilter
? and(eq(externalTransactions.companyId, params.companyId), matchFilter)
: eq(externalTransactions.companyId, params.companyId)
)
.orderBy(desc(externalTransactions.occurredAt));
// Fetch matched expense titles for display
const matchedExpenseIds = transactions
.map((tx) => tx.matchedExpenseId)
.filter((id): id is string => id !== null);
let matchedExpenseTitles: Record<string, string> = {};
if (matchedExpenseIds.length > 0) {
// Fetch all company expenses to build a title lookup for matched ones
const allExpenses = await db
.select({ id: expenses.id, title: expenses.title })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId));
for (const e of allExpenses) {
matchedExpenseTitles[e.id] = e.title;
}
}
// Pending expenses for matching dropdown (recent 200)
const matchableExpenses = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
currency: expenses.currency,
expenseDate: expenses.expenseDate
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(
eq(projects.companyId, params.companyId),
eq(expenses.status, 'pending')
)
)
.orderBy(desc(expenses.createdAt))
.limit(200);
return {
transactions,
matchedExpenseTitles,
matchableExpenses,
matchedFilter: matched
};
};
export const actions: Actions = {
matchExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
const expenseId = formData.get('expenseId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
if (!expenseId) return fail(400, { error: 'Expense ID is required' });
const [tx] = await db
.select({ id: externalTransactions.id })
.from(externalTransactions)
.where(and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId)))
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
await db
.update(externalTransactions)
.set({ matchedExpenseId: expenseId })
.where(eq(externalTransactions.id, txId));
await logCompanyEvent(
params.companyId,
user.id,
'transaction_matched',
`Matched external transaction to expense`,
{ txId, expenseId }
);
return { success: true };
},
unmatch: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const txId = formData.get('txId')?.toString();
if (!txId) return fail(400, { error: 'Transaction ID is required' });
const [tx] = await db
.select({ id: externalTransactions.id })
.from(externalTransactions)
.where(and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId)))
.limit(1);
if (!tx) return fail(404, { error: 'Transaction not found' });
await db
.update(externalTransactions)
.set({ matchedExpenseId: null })
.where(eq(externalTransactions.id, txId));
return { success: true };
}
};
@@ -0,0 +1,160 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const providerLabels: Record<string, string> = {
kasikorn_kbiz: 'Kasikorn K-Biz',
etherfi: 'Ether.fi',
manual: 'Manual'
};
function formatAmount(amount: string, currency: string): string {
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(Number(amount));
}
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
</script>
<svelte:head>
<title>Transactions - {data.company.name}</title>
</svelte:head>
<!-- System Admin / Experimental Banner -->
<div class="mb-5 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
<strong>System Admin Only — Experimental.</strong> External integrations are stubs. Transactions must be entered manually or imported.
</div>
{#if form?.error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- Header + filter -->
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a
href="../integrations"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
← Back to Integrations
</a>
<span class="text-gray-300 dark:text-gray-600">|</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
All Transactions
<span class="ml-1 text-sm font-normal text-gray-500 dark:text-gray-400">({data.transactions.length})</span>
</h2>
</div>
<!-- Filter pills -->
<div class="flex gap-2">
{#each [['all', 'All'], ['no', 'Unmatched'], ['yes', 'Matched']] as [val, label]}
<a
href="?matched={val}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{data.matchedFilter === val
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}"
>
{label}
</a>
{/each}
</div>
</div>
{#if data.transactions.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No transactions found for this filter.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Date</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Account</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Dir</th>
<th class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">Amount</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th>
</tr>
</thead>
<tbody>
{#each data.transactions as tx}
<tr class="border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatDate(tx.occurredAt)}
</td>
<td class="px-4 py-3">
<span class="text-gray-700 dark:text-gray-300">{tx.accountName}</span>
<br />
<span class="text-xs text-gray-400 dark:text-gray-500">
{providerLabels[tx.accountProvider] ?? tx.accountProvider}
</span>
</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{tx.direction === 'credit'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'}"
>
{tx.direction}
</span>
</td>
<td class="px-4 py-3 text-right font-mono font-medium text-gray-900 dark:text-white whitespace-nowrap">
{formatAmount(tx.amount, tx.currency)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-[180px] truncate" title={tx.description ?? ''}>
{tx.description ?? '—'}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-[140px] truncate" title={tx.counterparty ?? ''}>
{tx.counterparty ?? '—'}
</td>
<td class="px-4 py-3">
{#if tx.matchedExpenseId}
<div class="flex items-center gap-2">
<span class="truncate max-w-[140px] text-xs text-green-700 dark:text-green-300 font-medium" title={data.matchedExpenseTitles[tx.matchedExpenseId] ?? tx.matchedExpenseId}>
{data.matchedExpenseTitles[tx.matchedExpenseId] ?? 'Expense'}
</span>
<form method="POST" action="?/unmatch" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<button
type="submit"
class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 whitespace-nowrap"
>
Unmatch
</button>
</form>
</div>
{:else}
<form method="POST" action="?/matchExpense" use:enhance>
<input type="hidden" name="txId" value={tx.id} />
<select
name="expenseId"
onchange={(e) => {
if ((e.target as HTMLSelectElement).value) {
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
}
}}
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
>
<option value="">Match…</option>
{#each data.matchableExpenses as exp}
<option value={exp.id}>{exp.title} ({exp.currency} {exp.amount})</option>
{/each}
</select>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,118 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { invoices, parties } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
const directionFilter = url.searchParams.get('direction') || 'all';
const statusFilter = url.searchParams.get('status') || 'all';
const fromDate = url.searchParams.get('from') || '';
const toDate = url.searchParams.get('to') || '';
const conditions = [eq(invoices.companyId, params.companyId)];
if (directionFilter !== 'all') {
conditions.push(eq(invoices.direction, directionFilter as 'incoming' | 'outgoing'));
}
if (statusFilter !== 'all') {
conditions.push(
eq(invoices.status, statusFilter as 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled')
);
}
if (fromDate) {
conditions.push(gte(invoices.issueDate, fromDate));
}
if (toDate) {
conditions.push(lte(invoices.issueDate, toDate));
}
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
subtotal: invoices.subtotal,
vat: invoices.vat,
total: invoices.total,
currency: invoices.currency,
partyId: invoices.partyId,
partyName: parties.name
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(and(...conditions))
.orderBy(sql`${invoices.issueDate} desc`)
.limit(200);
return { invoices: invoiceList, directionFilter, statusFilter, fromDate, toDate };
};
export const actions: Actions = {
markSent: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const invoiceId = formData.get('invoiceId')?.toString();
if (!invoiceId) return fail(400, { error: 'Missing invoice ID' });
const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency })
.from(invoices)
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' });
await db
.update(invoices)
.set({ status: 'sent', updatedAt: new Date() })
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)));
await logCompanyEvent(
params.companyId,
user.id,
'invoice_sent',
`Marked invoice ${inv.invoiceNumber} as sent`,
{ invoiceId }
);
return { success: true };
},
markPaid: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const invoiceId = formData.get('invoiceId')?.toString();
if (!invoiceId) return fail(400, { error: 'Missing invoice ID' });
const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber, total: invoices.total, currency: invoices.currency })
.from(invoices)
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' });
await db
.update(invoices)
.set({ status: 'paid', updatedAt: new Date() })
.where(and(eq(invoices.id, invoiceId), eq(invoices.companyId, params.companyId)));
await logCompanyEvent(
params.companyId,
user.id,
'invoice_paid',
`Marked invoice ${inv.invoiceNumber} as paid`,
{ invoiceId }
);
return { success: true };
}
};
@@ -0,0 +1,161 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { PageData } from './$types';
let { data } = $props();
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
const statusColors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
};
</script>
<svelte:head>
<title>Invoices - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Invoices</h2>
{#if canManage}
<a
href="./invoices/new"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
New Invoice
</a>
{/if}
</div>
<!-- Filter bar -->
<div class="mb-4 flex flex-wrap gap-3 items-end">
<div class="flex gap-1">
{#each ['all', 'outgoing', 'incoming'] as d}
<a
href="?direction={d}&status={data.statusFilter}&from={data.fromDate}&to={data.toDate}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{data.directionFilter === d
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}"
>
{d.charAt(0).toUpperCase() + d.slice(1)}
</a>
{/each}
</div>
<div class="flex gap-1">
{#each ['all', 'draft', 'sent', 'paid', 'overdue', 'cancelled'] as s}
<a
href="?direction={data.directionFilter}&status={s}&from={data.fromDate}&to={data.toDate}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{data.statusFilter === s
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}"
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</a>
{/each}
</div>
<form method="GET" class="flex gap-2 items-center">
<input type="hidden" name="direction" value={data.directionFilter} />
<input type="hidden" name="status" value={data.statusFilter} />
<input type="date" name="from" value={data.fromDate}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm text-gray-900 dark:text-white" />
<span class="text-gray-400 text-sm">to</span>
<input type="date" name="to" value={data.toDate}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm text-gray-900 dark:text-white" />
<button type="submit" class="rounded-md bg-gray-100 dark:bg-gray-700 px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Filter
</button>
</form>
</div>
{#if data.invoices.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No invoices found.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Invoice #</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Party</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Direction</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Issue Date</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Due Date</th>
<th class="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">Total</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Status</th>
{#if canManage}
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Actions</th>
{/if}
</tr>
</thead>
<tbody>
{#each data.invoices as inv}
<tr class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-3">
<button
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left"
onclick={() => goto(`./invoices/${inv.id}`)}
>
{inv.invoiceNumber}
</button>
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">{inv.partyName}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{inv.direction === 'outgoing'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'}">
{inv.direction}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{formatDate(inv.issueDate)}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{inv.dueDate ? formatDate(inv.dueDate) : '—'}</td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(inv.total, inv.currency)}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusColors[inv.status]}">
{inv.status}
</span>
</td>
{#if canManage}
<td class="px-4 py-3">
<div class="flex gap-1">
{#if inv.status === 'draft'}
<form method="POST" action="?/markSent" use:enhance>
<input type="hidden" name="invoiceId" value={inv.id} />
<button type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50">
Mark Sent
</button>
</form>
{/if}
{#if inv.status === 'sent' || inv.status === 'overdue'}
<form method="POST" action="?/markPaid" use:enhance>
<input type="hidden" name="invoiceId" value={inv.id} />
<button type="submit"
class="rounded px-2 py-1 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/50">
Mark Paid
</button>
</form>
{/if}
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,175 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
invoices,
invoiceLineItems,
parties,
expenses,
projects,
packages
} from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
const [invoice] = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
subtotal: invoices.subtotal,
vat: invoices.vat,
total: invoices.total,
currency: invoices.currency,
notes: invoices.notes,
expenseId: invoices.expenseId,
createdAt: invoices.createdAt,
partyId: invoices.partyId,
partyName: parties.name,
partyEmail: parties.email,
partyPhone: parties.phone,
partyTaxId: parties.taxId,
partyAddressLine1: parties.addressLine1,
partyAddressLine2: parties.addressLine2,
partyCity: parties.city,
partyPostalCode: parties.postalCode,
partyCountry: parties.country
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!invoice) error(404, 'Invoice not found');
const lineItems = await db
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, params.invoiceId));
// Load active projects for linkExpense form
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)));
const linkedPackages = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
status: packages.status,
direction: packages.direction
})
.from(packages)
.where(eq(packages.invoiceId, params.invoiceId))
.orderBy(packages.createdAt);
return { invoice, lineItems, projects: projectList, linkedPackages };
};
export const actions: Actions = {
updateStatus: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const newStatus = formData.get('status')?.toString() as
| 'draft'
| 'sent'
| 'paid'
| 'overdue'
| 'cancelled'
| undefined;
const validStatuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'];
if (!newStatus || !validStatuses.includes(newStatus)) {
return fail(400, { error: 'Invalid status' });
}
const [inv] = await db
.select({ invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' });
await db
.update(invoices)
.set({ status: newStatus, updatedAt: new Date() })
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
if (newStatus === 'sent') {
await logCompanyEvent(params.companyId, user.id, 'invoice_sent', `Marked invoice ${inv.invoiceNumber} as sent`, { invoiceId: params.invoiceId });
} else if (newStatus === 'paid') {
await logCompanyEvent(params.companyId, user.id, 'invoice_paid', `Marked invoice ${inv.invoiceNumber} as paid`, { invoiceId: params.invoiceId });
}
return { success: true };
},
linkExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const projectId = formData.get('projectId')?.toString();
if (!projectId) return fail(400, { error: 'Project is required' });
const [inv] = await db
.select({
invoiceNumber: invoices.invoiceNumber,
total: invoices.total,
currency: invoices.currency,
issueDate: invoices.issueDate,
notes: invoices.notes,
partyId: invoices.partyId,
partyName: parties.name,
direction: invoices.direction
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!inv) return fail(404, { error: 'Invoice not found' });
if (inv.direction !== 'incoming') return fail(400, { error: 'Only incoming invoices can be linked to expenses' });
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!project) return fail(404, { error: 'Project not found' });
const [newExpense] = await db
.insert(expenses)
.values({
projectId,
partyId: inv.partyId,
submittedBy: user.id,
title: `${inv.invoiceNumber}${inv.partyName}`,
description: inv.notes ?? `Invoice ${inv.invoiceNumber} from ${inv.partyName}`,
amount: inv.total,
currency: inv.currency,
expenseDate: inv.issueDate,
status: 'approved',
approvedBy: user.id,
reviewedAt: new Date()
})
.returning({ id: expenses.id });
await db
.update(invoices)
.set({ expenseId: newExpense.id, updatedAt: new Date() })
.where(eq(invoices.id, params.invoiceId));
return { linked: true };
}
};
@@ -0,0 +1,278 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
const inv = $derived(data.invoice);
const statusColors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
};
const nextStatuses: Record<string, string[]> = {
draft: ['sent', 'cancelled'],
sent: ['paid', 'overdue', 'cancelled'],
overdue: ['paid', 'cancelled'],
paid: [],
cancelled: []
};
let showLinkExpense = $state(false);
let selectedProject = $state('');
</script>
<svelte:head>
<title>Invoice {inv.invoiceNumber} - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center gap-3">
<a href="../invoices" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
← Invoices
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{inv.invoiceNumber}</h2>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusColors[inv.status]}">
{inv.status}
</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{inv.direction === 'outgoing'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'}">
{inv.direction}
</span>
</div>
{#if form?.error}
<div class="rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
{#if form?.linked}
<div class="rounded-md bg-green-50 dark:bg-green-900/30 px-4 py-3 text-sm text-green-700 dark:text-green-300">
Expense created and linked successfully.
</div>
{/if}
<!-- Invoice header card -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<div class="flex items-start justify-between">
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">{data.company.name}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{inv.direction === 'outgoing' ? 'INVOICE' : 'BILL / RECEIPT'}
</p>
</div>
<div class="text-right text-sm text-gray-600 dark:text-gray-400 space-y-1">
<p><span class="font-medium text-gray-900 dark:text-white">#{inv.invoiceNumber}</span></p>
<p>Issued: {formatDate(inv.issueDate)}</p>
{#if inv.dueDate}
<p>Due: {formatDate(inv.dueDate)}</p>
{/if}
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-6">
{#if inv.direction === 'outgoing'}
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">From</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{data.company.name}</p>
</div>
{/if}
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
{inv.direction === 'outgoing' ? 'Bill To' : 'From Supplier'}
</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{inv.partyName}</p>
{#if inv.partyEmail}<p class="text-sm text-gray-500 dark:text-gray-400">{inv.partyEmail}</p>{/if}
{#if inv.partyPhone}<p class="text-sm text-gray-500 dark:text-gray-400">{inv.partyPhone}</p>{/if}
{#if inv.partyTaxId}<p class="text-sm text-gray-500 dark:text-gray-400">Tax: {inv.partyTaxId}</p>{/if}
{#if inv.partyAddressLine1}
<p class="text-sm text-gray-500 dark:text-gray-400">
{inv.partyAddressLine1}
{#if inv.partyAddressLine2}, {inv.partyAddressLine2}{/if}
</p>
{/if}
{#if inv.partyCity || inv.partyPostalCode}
<p class="text-sm text-gray-500 dark:text-gray-400">{inv.partyCity ?? ''} {inv.partyPostalCode ?? ''}</p>
{/if}
{#if inv.partyCountry}<p class="text-sm text-gray-500 dark:text-gray-400">{inv.partyCountry}</p>{/if}
</div>
</div>
</div>
<!-- Line items table -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Items</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="pb-2 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
<th class="pb-2 text-right font-medium text-gray-500 dark:text-gray-400">Qty</th>
<th class="pb-2 text-right font-medium text-gray-500 dark:text-gray-400">Unit Price</th>
<th class="pb-2 text-right font-medium text-gray-500 dark:text-gray-400">Total</th>
</tr>
</thead>
<tbody>
{#each data.lineItems as li}
<tr class="border-b border-gray-50 dark:border-gray-700/50">
<td class="py-2 text-gray-900 dark:text-white">{li.description}</td>
<td class="py-2 text-right text-gray-600 dark:text-gray-400">{parseFloat(li.quantity).toLocaleString()}</td>
<td class="py-2 text-right text-gray-600 dark:text-gray-400">{formatCurrency(li.unitPrice, inv.currency)}</td>
<td class="py-2 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(li.total, inv.currency)}</td>
</tr>
{/each}
</tbody>
</table>
<!-- Totals box -->
<div class="mt-4 ml-auto max-w-xs border border-gray-200 dark:border-gray-700 rounded-md p-4 space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Subtotal</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(inv.subtotal, inv.currency)}</span>
</div>
{#if parseFloat(inv.vat) > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">VAT 7%</span>
<span class="font-medium text-gray-900 dark:text-white">{formatCurrency(inv.vat, inv.currency)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
<span class="font-semibold text-gray-900 dark:text-white">Total</span>
<span class="font-bold text-base text-gray-900 dark:text-white">{formatCurrency(inv.total, inv.currency)}</span>
</div>
</div>
{#if inv.notes}
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<span class="font-medium">Notes:</span> {inv.notes}
</div>
{/if}
</div>
<!-- Linked packages -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
<a
href="/companies/{data.company.id}/packages/new?invoiceId={inv.id}&direction={inv.direction}"
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
+ Add package
</a>
</div>
{#if data.linkedPackages.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No packages linked.</p>
{:else}
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
{#each data.linkedPackages as p}
<li>
<a
href="/companies/{data.company.id}/packages/{p.id}"
class="flex items-center justify-between py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded px-2"
>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400 dark:text-gray-500">{p.direction === 'incoming' ? '↓' : '↑'}</span>
<span class="font-mono text-sm text-gray-900 dark:text-white">{p.trackingNumber}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{p.carrier}</span>
</div>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{p.status}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Actions -->
{#if canManage}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<div class="flex flex-wrap items-center gap-3">
<!-- Status transitions -->
{#each nextStatuses[inv.status] ?? [] as targetStatus}
<form method="POST" action="?/updateStatus" use:enhance>
<input type="hidden" name="status" value={targetStatus} />
<button type="submit"
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{targetStatus === 'paid' ? 'bg-green-600 text-white hover:bg-green-700' :
targetStatus === 'sent' ? 'bg-blue-600 text-white hover:bg-blue-700' :
targetStatus === 'cancelled' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' :
targetStatus === 'overdue' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}">
Mark {targetStatus.charAt(0).toUpperCase() + targetStatus.slice(1)}
</button>
</form>
{/each}
<!-- PDF download -->
<a href="/companies/{data.company.id}/invoices/{inv.id}/pdf" target="_blank"
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Download PDF
</a>
</div>
<!-- Link expense (incoming only, not already linked) -->
{#if inv.direction === 'incoming' && !inv.expenseId}
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
{#if !showLinkExpense}
<button
type="button"
onclick={() => (showLinkExpense = true)}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Create linked expense from this invoice
</button>
{:else}
<form method="POST" action="?/linkExpense" use:enhance class="flex items-end gap-3">
<div>
<label for="projectId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Link to project
</label>
<select id="projectId" name="projectId" required bind:value={selectedProject}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none">
<option value="">Select project...</option>
{#each data.projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
</div>
<button type="submit"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Expense
</button>
<button type="button" onclick={() => (showLinkExpense = false)}
class="text-sm text-gray-500 dark:text-gray-400 hover:underline">
Cancel
</button>
</form>
{/if}
</div>
{:else if inv.expenseId}
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
<p class="text-sm text-green-600 dark:text-green-400">
Linked to expense.
</p>
</div>
{/if}
</div>
{:else}
<div class="flex gap-3">
<a href="/companies/{data.company.id}/invoices/{inv.id}/pdf" target="_blank"
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Download PDF
</a>
</div>
{/if}
</div>
@@ -0,0 +1,93 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { invoices, invoiceLineItems, parties, companies } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { generateInvoicePDF } from '$lib/server/invoices/pdf.js';
import { eq, and } from 'drizzle-orm';
export const GET: RequestHandler = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
const [invoice] = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
subtotal: invoices.subtotal,
vat: invoices.vat,
total: invoices.total,
currency: invoices.currency,
notes: invoices.notes,
partyId: invoices.partyId
})
.from(invoices)
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
.limit(1);
if (!invoice) error(404, 'Invoice not found');
const [party] = await db
.select({
name: parties.name,
email: parties.email,
phone: parties.phone,
taxId: parties.taxId,
addressLine1: parties.addressLine1,
addressLine2: parties.addressLine2,
city: parties.city,
postalCode: parties.postalCode,
country: parties.country
})
.from(parties)
.where(eq(parties.id, invoice.partyId))
.limit(1);
if (!party) error(404, 'Party not found');
const [company] = await db
.select({ name: companies.name, currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) error(404, 'Company not found');
const lineItems = await db
.select()
.from(invoiceLineItems)
.where(eq(invoiceLineItems.invoiceId, params.invoiceId));
const pdfBytes = await generateInvoicePDF({
company: { name: company.name, currency: invoice.currency },
party,
invoice: {
number: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate ?? null,
direction: invoice.direction,
subtotal: parseFloat(invoice.subtotal),
vat: parseFloat(invoice.vat),
total: parseFloat(invoice.total),
notes: invoice.notes ?? null
},
lineItems: lineItems.map((li) => ({
description: li.description,
quantity: parseFloat(li.quantity),
unitPrice: parseFloat(li.unitPrice),
total: parseFloat(li.total)
}))
});
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
return new Response(pdfBytes.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
};
@@ -0,0 +1,122 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { parties, invoices, invoiceLineItems } from '$lib/server/db/schema.js';
import { eq, and, isNull, count } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const direction = (url.searchParams.get('direction') || 'outgoing') as 'incoming' | 'outgoing';
const preselectedPartyId = url.searchParams.get('partyId') || '';
const partyList = await db
.select({
id: parties.id,
name: parties.name,
type: parties.type
})
.from(parties)
.where(and(eq(parties.companyId, params.companyId), eq(parties.isActive, true), isNull(parties.deletedAt)))
.orderBy(parties.name);
const [countResult] = await db
.select({ count: count() })
.from(invoices)
.where(eq(invoices.companyId, params.companyId));
const nextNumber = (countResult?.count ?? 0) + 1;
const year = new Date().getFullYear();
const suggestedInvoiceNumber = `INV-${year}-${String(nextNumber).padStart(3, '0')}`;
return { parties: partyList, direction, suggestedInvoiceNumber, preselectedPartyId };
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const partyId = formData.get('partyId')?.toString();
const direction = formData.get('direction')?.toString() as 'incoming' | 'outgoing';
const invoiceNumber = formData.get('invoiceNumber')?.toString().trim();
const issueDate = formData.get('issueDate')?.toString();
const dueDate = formData.get('dueDate')?.toString() || null;
const currency = formData.get('currency')?.toString() || 'THB';
const notes = formData.get('notes')?.toString().trim() || null;
const includeVat = formData.get('includeVat') === 'on';
if (!partyId) return fail(400, { error: 'Party is required' });
if (!direction || !['incoming', 'outgoing'].includes(direction)) {
return fail(400, { error: 'Invalid direction' });
}
if (!invoiceNumber) return fail(400, { error: 'Invoice number is required' });
if (!issueDate) return fail(400, { error: 'Issue date is required' });
const descriptions = formData.getAll('description[]').map((v: FormDataEntryValue) => v.toString());
const quantities = formData.getAll('quantity[]').map((v: FormDataEntryValue) => parseFloat(v.toString()) || 0);
const unitPrices = formData.getAll('unitPrice[]').map((v: FormDataEntryValue) => parseFloat(v.toString()) || 0);
if (descriptions.length === 0 || descriptions.every((d: string) => !d.trim())) {
return fail(400, { error: 'At least one line item is required' });
}
interface RawLineItem { description: string; quantity: number; unitPrice: number; total: number }
const lineItems: RawLineItem[] = descriptions
.map((desc: string, i: number) => ({
description: desc.trim(),
quantity: quantities[i] ?? 1,
unitPrice: unitPrices[i] ?? 0,
total: (quantities[i] ?? 1) * (unitPrices[i] ?? 0)
}))
.filter((li: RawLineItem) => li.description);
if (lineItems.length === 0) return fail(400, { error: 'At least one line item with a description is required' });
const subtotal = lineItems.reduce((sum: number, li: RawLineItem) => sum + li.total, 0);
const vat = includeVat ? Math.round(subtotal * 0.07 * 100) / 100 : 0;
const total = subtotal + vat;
const [invoice] = await db
.insert(invoices)
.values({
companyId: params.companyId,
partyId,
direction,
invoiceNumber,
issueDate,
dueDate,
currency,
subtotal: subtotal.toFixed(2),
vat: vat.toFixed(2),
total: total.toFixed(2),
notes,
status: 'draft'
})
.returning({ id: invoices.id });
await db.insert(invoiceLineItems).values(
lineItems.map((li: RawLineItem) => ({
invoiceId: invoice.id,
description: li.description,
quantity: li.quantity.toFixed(2),
unitPrice: li.unitPrice.toFixed(2),
total: li.total.toFixed(2)
}))
);
await logCompanyEvent(
params.companyId,
user.id,
'invoice_created',
`Created invoice ${invoiceNumber} (${direction}) for ${currency} ${total.toFixed(2)}`,
{ invoiceId: invoice.id, direction, total }
);
redirect(303, `/companies/${params.companyId}/invoices/${invoice.id}`);
}
};
@@ -0,0 +1,260 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
let { data, form } = $props();
// svelte-ignore state_referenced_locally
let direction = $state(data.direction);
let includeVat = $state(false);
let currency = $state('THB');
interface LineItem {
description: string;
quantity: number;
unitPrice: number;
}
let items: LineItem[] = $state([{ description: '', quantity: 1, unitPrice: 0 }]);
const today = new Date().toISOString().split('T')[0];
function addItem() {
items = [...items, { description: '', quantity: 1, unitPrice: 0 }];
}
function removeItem(idx: number) {
if (items.length === 1) return;
items = items.filter((_, i) => i !== idx);
}
const filteredParties = $derived(
data.parties.filter((p) =>
direction === 'outgoing'
? p.type === 'customer' || p.type === 'both'
: p.type === 'supplier' || p.type === 'both'
)
);
const lineSubtotals = $derived(items.map((li) => li.quantity * li.unitPrice));
const subtotal = $derived(lineSubtotals.reduce((sum, v) => sum + v, 0));
const vat = $derived(includeVat ? Math.round(subtotal * 0.07 * 100) / 100 : 0);
const total = $derived(subtotal + vat);
</script>
<svelte:head>
<title>New Invoice - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<div class="mb-6 flex items-center gap-3">
<a href="../invoices" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
← Invoices
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">New Invoice</h2>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<form method="POST" class="space-y-6">
<!-- Header section -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Invoice Details</h3>
<!-- Direction -->
<fieldset>
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direction</legend>
<div class="flex gap-4">
{#each ['outgoing', 'incoming'] as d}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="direction"
value={d}
checked={direction === d}
onchange={() => { direction = d as 'incoming' | 'outgoing'; }}
class="text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{d === 'outgoing' ? 'Outgoing (Sales invoice to customer)' : 'Incoming (Bill from supplier)'}
</span>
</label>
{/each}
</div>
</fieldset>
<!-- Party -->
<div>
<label for="partyId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{direction === 'outgoing' ? 'Customer' : 'Supplier'} <span class="text-red-500">*</span>
</label>
<select id="partyId" name="partyId" required
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none">
<option value="">Select a contact...</option>
{#each filteredParties as p}
<option value={p.id} selected={p.id === data.preselectedPartyId}>{p.name}</option>
{/each}
</select>
{#if filteredParties.length === 0}
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
No {direction === 'outgoing' ? 'customers' : 'suppliers'} found.
<a href="../parties/new" class="underline">Add a contact first.</a>
</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="invoiceNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Invoice Number <span class="text-red-500">*</span>
</label>
<input id="invoiceNumber" name="invoiceNumber" type="text" required
value={data.suggestedInvoiceNumber}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="currency" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
<select id="currency" name="currency"
onchange={(e) => { currency = (e.target as HTMLSelectElement).value; }}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none">
<option value="THB">THB</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
<div>
<label for="issueDate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Issue Date <span class="text-red-500">*</span>
</label>
<input id="issueDate" name="issueDate" type="date" required value={today}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="dueDate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date</label>
<input id="dueDate" name="dueDate" type="date"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
<!-- Line items -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Line Items</h3>
<div class="space-y-2">
<div class="grid grid-cols-12 gap-2 text-xs font-medium text-gray-500 dark:text-gray-400 px-1">
<div class="col-span-6">Description</div>
<div class="col-span-2 text-right">Qty</div>
<div class="col-span-2 text-right">Unit Price</div>
<div class="col-span-2 text-right">Total</div>
</div>
{#each items as item, idx}
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-6">
<input
type="text"
name="description[]"
bind:value={item.description}
placeholder="Description"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div class="col-span-2">
<input
type="number"
name="quantity[]"
bind:value={item.quantity}
min="0"
step="0.01"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white text-right focus:border-blue-500 focus:outline-none"
/>
</div>
<div class="col-span-2">
<input
type="number"
name="unitPrice[]"
bind:value={item.unitPrice}
min="0"
step="0.01"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white text-right focus:border-blue-500 focus:outline-none"
/>
</div>
<div class="col-span-1 text-right text-sm font-medium text-gray-900 dark:text-white">
{(item.quantity * item.unitPrice).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div class="col-span-1 flex justify-end">
<button
type="button"
onclick={() => removeItem(idx)}
disabled={items.length === 1}
class="text-gray-400 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed text-lg leading-none"
aria-label="Remove line"
>
×
</button>
</div>
</div>
{/each}
</div>
<button
type="button"
onclick={addItem}
class="mt-3 text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
+ Add line
</button>
<!-- Totals -->
<div class="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Subtotal</span>
<span class="font-medium text-gray-900 dark:text-white">
{subtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} {currency}
</span>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="includeVat" bind:checked={includeVat} class="text-blue-600" />
<span class="text-gray-500 dark:text-gray-400">VAT 7%</span>
</label>
<span class="font-medium text-gray-900 dark:text-white">
{vat.toLocaleString('en-US', { minimumFractionDigits: 2 })} {currency}
</span>
</div>
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
<span class="font-semibold text-gray-900 dark:text-white">Total</span>
<span class="font-bold text-lg text-gray-900 dark:text-white">
{total.toLocaleString('en-US', { minimumFractionDigits: 2 })} {currency}
</span>
</div>
</div>
</div>
<!-- Notes -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="notes" name="notes" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"></textarea>
</div>
<div class="flex justify-end gap-3">
<a href="../invoices"
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</a>
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Invoice
</button>
</div>
</form>
</div>
@@ -0,0 +1,90 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { packages, invoices, expenses, parties } from '$lib/server/db/schema.js';
import { eq, and, ilike, or, sql } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['user', 'manager', 'admin', 'hr']);
const direction = url.searchParams.get('direction') || '';
const status = url.searchParams.get('status') || '';
const carrier = url.searchParams.get('carrier') || '';
const search = url.searchParams.get('search') || '';
const conditions = [eq(packages.companyId, params.companyId)];
if (direction && direction !== 'all') {
conditions.push(eq(packages.direction, direction as 'incoming' | 'outgoing'));
}
if (status && status !== 'all') {
conditions.push(
eq(
packages.status,
status as
| 'pending'
| 'in_transit'
| 'out_for_delivery'
| 'delivered'
| 'exception'
| 'returned'
| 'cancelled'
)
);
}
if (carrier && carrier !== 'all') {
conditions.push(
eq(
packages.carrier,
carrier as
| 'ups'
| 'fedex'
| 'dhl'
| 'usps'
| 'flash_express'
| 'kerry_th'
| 'jnt_express'
| 'thailand_post'
| 'other'
)
);
}
if (search.trim()) {
conditions.push(
or(
ilike(packages.trackingNumber, `%${search.trim()}%`),
ilike(packages.description, `%${search.trim()}%`)
)!
);
}
const packageList = await db
.select({
id: packages.id,
direction: packages.direction,
carrier: packages.carrier,
trackingNumber: packages.trackingNumber,
status: packages.status,
description: packages.description,
estimatedDelivery: packages.estimatedDelivery,
updatedAt: packages.updatedAt,
invoiceId: packages.invoiceId,
invoiceNumber: invoices.invoiceNumber,
expenseId: packages.expenseId,
expenseTitle: expenses.title,
partyId: packages.partyId,
partyName: parties.name
})
.from(packages)
.leftJoin(invoices, eq(packages.invoiceId, invoices.id))
.leftJoin(expenses, eq(packages.expenseId, expenses.id))
.leftJoin(parties, eq(packages.partyId, parties.id))
.where(and(...conditions))
.orderBy(sql`${packages.updatedAt} desc`)
.limit(300);
return {
packages: packageList,
filters: { direction, status, carrier, search }
};
};
@@ -0,0 +1,223 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date.js';
import { timeAgo } from '$lib/utils/date.js';
import type { PageData } from './$types';
let { data } = $props();
const CARRIERS = [
{ value: 'ups', label: 'UPS' },
{ value: 'fedex', label: 'FedEx' },
{ value: 'dhl', label: 'DHL' },
{ value: 'usps', label: 'USPS' },
{ value: 'flash_express', label: 'Flash Express' },
{ value: 'kerry_th', label: 'Kerry Express (TH)' },
{ value: 'jnt_express', label: 'J&T Express' },
{ value: 'thailand_post', label: 'Thailand Post' },
{ value: 'other', label: 'Other' }
];
const STATUSES = [
{ value: 'pending', label: 'Pending' },
{ value: 'in_transit', label: 'In Transit' },
{ value: 'out_for_delivery', label: 'Out for Delivery' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'exception', label: 'Exception' },
{ value: 'returned', label: 'Returned' },
{ value: 'cancelled', label: 'Cancelled' }
];
function statusBadge(status: string): string {
const map: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
in_transit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
out_for_delivery: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
delivered: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
exception: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
returned: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
};
return map[status] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
function statusLabel(status: string): string {
return STATUSES.find((s) => s.value === status)?.label ?? status;
}
function carrierLabel(carrier: string): string {
return CARRIERS.find((c) => c.value === carrier)?.label ?? carrier;
}
const direction = $derived(data.filters.direction);
const status = $derived(data.filters.status);
const carrier = $derived(data.filters.carrier);
const search = $derived(data.filters.search);
</script>
<svelte:head>
<title>Packages - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Packages</h2>
<a
href="./packages/new"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
New Package
</a>
</div>
<!-- Filter bar -->
<form method="GET" class="mb-4 flex flex-wrap items-end gap-3">
<!-- Direction pills -->
<div class="flex gap-1">
{#each [{ value: '', label: 'All' }, { value: 'incoming', label: 'Incoming' }, { value: 'outgoing', label: 'Outgoing' }] as d}
<button
type="submit"
name="direction"
value={d.value}
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{direction === d.value || (d.value === '' && !direction)
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}"
>
{d.label}
</button>
{/each}
</div>
<input type="hidden" name="direction" value={direction} />
<!-- Status select -->
<select
name="status"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm text-gray-900 dark:text-white"
>
<option value="">All Statuses</option>
{#each STATUSES as s}
<option value={s.value} selected={status === s.value}>{s.label}</option>
{/each}
</select>
<!-- Carrier select -->
<select
name="carrier"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm text-gray-900 dark:text-white"
>
<option value="">All Carriers</option>
{#each CARRIERS as c}
<option value={c.value} selected={carrier === c.value}>{c.label}</option>
{/each}
</select>
<!-- Search -->
<input
type="text"
name="search"
placeholder="Tracking # or description..."
value={search}
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-1 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-gray-100 dark:bg-gray-700 px-3 py-1 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
>
Filter
</button>
</form>
{#if data.packages.length === 0}
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center"
>
<p class="text-gray-500 dark:text-gray-400">No packages found.</p>
</div>
{:else}
<div
class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
>
<table class="w-full text-sm">
<thead>
<tr
class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400 w-8"
>Dir</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Carrier</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Tracking #</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Status</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Description</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Linked</th
>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">ETA</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400"
>Updated</th
>
</tr>
</thead>
<tbody>
{#each data.packages as pkg}
<tr
class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
onclick={() => { window.location.href = `./packages/${pkg.id}`; }}
>
<td class="px-4 py-3 text-center text-base">
{#if pkg.direction === 'incoming'}
<span class="text-blue-500 dark:text-blue-400" title="Incoming"></span>
{:else}
<span class="text-orange-500 dark:text-orange-400" title="Outgoing"></span>
{/if}
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">{carrierLabel(pkg.carrier)}</td>
<td class="px-4 py-3">
<a
href="./packages/{pkg.id}"
class="font-mono font-medium text-blue-600 dark:text-blue-400 hover:underline"
onclick={(e) => e.stopPropagation()}
>
{pkg.trackingNumber}
</a>
</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(pkg.status)}">
{statusLabel(pkg.status)}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-xs truncate">
{pkg.description ?? '—'}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{#if pkg.invoiceNumber}
<span class="text-xs">Invoice #{pkg.invoiceNumber}</span>
{:else if pkg.expenseTitle}
<span class="text-xs">{pkg.expenseTitle}</span>
{:else}
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{pkg.estimatedDelivery ? formatDate(pkg.estimatedDelivery) : '—'}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-500 text-xs whitespace-nowrap">
{timeAgo(pkg.updatedAt)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,505 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
packages,
packageEvents,
shippingAccounts,
invoices,
invoiceLineItems,
expenses,
parties,
projects
} from '$lib/server/db/schema.js';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { requireCompanyRoleAny, requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { fetchTrackingStatus } from '$lib/server/shipping/index.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['user', 'manager', 'admin', 'hr']);
const [pkg] = await db
.select({
id: packages.id,
companyId: packages.companyId,
direction: packages.direction,
carrier: packages.carrier,
trackingNumber: packages.trackingNumber,
status: packages.status,
currentLocation: packages.currentLocation,
description: packages.description,
recipientName: packages.recipientName,
estimatedDelivery: packages.estimatedDelivery,
shippedAt: packages.shippedAt,
deliveredAt: packages.deliveredAt,
weightKg: packages.weightKg,
shippingCost: packages.shippingCost,
currency: packages.currency,
invoiceId: packages.invoiceId,
customsInvoiceId: packages.customsInvoiceId,
expenseId: packages.expenseId,
partyId: packages.partyId,
notes: packages.notes,
lastRefreshedAt: packages.lastRefreshedAt,
createdAt: packages.createdAt,
updatedAt: packages.updatedAt
})
.from(packages)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) error(404, 'Package not found');
const events = await db
.select()
.from(packageEvents)
.where(eq(packageEvents.packageId, params.packageId))
.orderBy(packageEvents.occurredAt);
// Reverse to newest-first for display
events.reverse();
// Linked data
let linkedInvoice: { id: string; invoiceNumber: string } | null = null;
if (pkg.invoiceId) {
const [inv] = await db
.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(eq(invoices.id, pkg.invoiceId))
.limit(1);
linkedInvoice = inv ?? null;
}
let customsInvoice: { id: string; invoiceNumber: string; total: string; currency: string; status: string } | null = null;
if (pkg.customsInvoiceId) {
const [inv] = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
total: invoices.total,
currency: invoices.currency,
status: invoices.status
})
.from(invoices)
.where(eq(invoices.id, pkg.customsInvoiceId))
.limit(1);
customsInvoice = inv ?? null;
}
let linkedExpense: { id: string; title: string } | null = null;
if (pkg.expenseId) {
const [exp] = await db
.select({ id: expenses.id, title: expenses.title })
.from(expenses)
.where(eq(expenses.id, pkg.expenseId))
.limit(1);
linkedExpense = exp ?? null;
}
let linkedParty: { id: string; name: string } | null = null;
if (pkg.partyId) {
const [party] = await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(eq(parties.id, pkg.partyId))
.limit(1);
linkedParty = party ?? null;
}
// Shipping account credentials for refresh
const [shippingAccount] = await db
.select({ credentialsEncrypted: shippingAccounts.credentialsEncrypted })
.from(shippingAccounts)
.where(
and(
eq(shippingAccounts.companyId, params.companyId),
eq(shippingAccounts.carrier, pkg.carrier)
)
)
.limit(1);
// Linked invoices/expenses/parties for edit modal
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
partyName: parties.name
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(eq(invoices.companyId, params.companyId))
.orderBy(invoices.invoiceNumber);
const expenseList = await db
.select({
id: expenses.id,
title: expenses.title,
projectName: projects.name
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId))
.orderBy(expenses.title);
const partyList = await db
.select({ id: parties.id, name: parties.name, type: parties.type })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), eq(parties.isActive, true), isNull(parties.deletedAt)))
.orderBy(parties.name);
return {
package: pkg,
events,
linkedInvoice,
customsInvoice,
linkedExpense,
linkedParty,
credentialsEncrypted: shippingAccount?.credentialsEncrypted ?? null,
invoices: invoiceList,
expenses: expenseList,
parties: partyList
};
};
export const actions: Actions = {
refreshStatus: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'user',
'manager',
'admin',
'hr'
]);
const [pkg] = await db
.select({
carrier: packages.carrier,
trackingNumber: packages.trackingNumber,
credentialsEncrypted: shippingAccounts.credentialsEncrypted
})
.from(packages)
.leftJoin(
shippingAccounts,
and(
eq(shippingAccounts.companyId, packages.companyId),
eq(shippingAccounts.carrier, packages.carrier)
)
)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) return fail(404, { refreshError: 'Package not found' });
let credentials: unknown;
try {
credentials = JSON.parse(pkg.credentialsEncrypted ?? '{}');
} catch {
credentials = {};
}
let statusResult;
try {
statusResult = await fetchTrackingStatus(pkg.carrier, pkg.trackingNumber, credentials);
} catch (err: any) {
return fail(400, { refreshError: err.message ?? 'Failed to fetch tracking status' });
}
// Fetch existing events to de-duplicate
const existing = await db
.select({
occurredAt: packageEvents.occurredAt,
status: packageEvents.status,
description: packageEvents.description
})
.from(packageEvents)
.where(eq(packageEvents.packageId, params.packageId));
let newCount = 0;
for (const ev of statusResult.events) {
const duplicate = existing.some(
(e) =>
e.occurredAt.getTime() === ev.occurredAt.getTime() &&
e.status === ev.status &&
e.description === ev.description
);
if (!duplicate) {
await db.insert(packageEvents).values({
packageId: params.packageId,
occurredAt: ev.occurredAt,
status: ev.status,
location: ev.location,
description: ev.description,
source: pkg.carrier
});
newCount++;
}
}
await db
.update(packages)
.set({
status: statusResult.status,
currentLocation: statusResult.currentLocation,
estimatedDelivery: statusResult.estimatedDelivery
? statusResult.estimatedDelivery.toISOString().split('T')[0]
: null,
lastRefreshedAt: new Date(),
updatedAt: new Date()
})
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)));
await logCompanyEvent(
params.companyId,
user.id,
'package_status_refreshed',
`Refreshed tracking for ${pkg.trackingNumber}: ${newCount} new event(s)`,
{ packageId: params.packageId, newEventCount: newCount }
);
return { refreshed: true, newCount };
},
updateManual: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'user',
'manager',
'admin',
'hr'
]);
const formData = await request.formData();
const status = formData.get('status')?.toString() as string | undefined;
const location = formData.get('location')?.toString().trim() || null;
const description = formData.get('description')?.toString().trim() || null;
const occurredAtRaw = formData.get('occurredAt')?.toString() || null;
const validStatuses = [
'pending',
'in_transit',
'out_for_delivery',
'delivered',
'exception',
'returned',
'cancelled'
];
if (!status || !validStatuses.includes(status)) {
return fail(400, { updateError: 'Valid status is required' });
}
const occurredAt = occurredAtRaw ? new Date(occurredAtRaw) : new Date();
await db.insert(packageEvents).values({
packageId: params.packageId,
occurredAt,
status: status as any,
location,
description,
source: 'manual'
});
const updates: Record<string, any> = {
status,
updatedAt: new Date()
};
if (location) updates.currentLocation = location;
if (status === 'delivered') updates.deliveredAt = new Date();
await db
.update(packages)
.set(updates)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)));
const [pkg] = await db
.select({ trackingNumber: packages.trackingNumber })
.from(packages)
.where(eq(packages.id, params.packageId))
.limit(1);
const event = status === 'delivered' ? 'package_delivered' : 'package_updated';
await logCompanyEvent(
params.companyId,
user.id,
event,
`Manually updated ${pkg?.trackingNumber ?? params.packageId} to ${status}`,
{ packageId: params.packageId, status }
);
return { updated: true };
},
updatePackage: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'user',
'manager',
'admin',
'hr'
]);
const formData = await request.formData();
const description = formData.get('description')?.toString().trim() || null;
const recipientName = formData.get('recipientName')?.toString().trim() || null;
const estimatedDelivery = formData.get('estimatedDelivery')?.toString() || null;
const weightKgRaw = formData.get('weightKg')?.toString() || null;
const shippingCostRaw = formData.get('shippingCost')?.toString() || null;
const notes = formData.get('notes')?.toString().trim() || null;
const invoiceId = formData.get('invoiceId')?.toString() || null;
const expenseId = formData.get('expenseId')?.toString() || null;
const partyId = formData.get('partyId')?.toString() || null;
const weightKg = weightKgRaw ? parseFloat(weightKgRaw).toFixed(3) : null;
const shippingCost = shippingCostRaw ? parseFloat(shippingCostRaw).toFixed(2) : null;
await db
.update(packages)
.set({
description,
recipientName,
estimatedDelivery: estimatedDelivery || null,
weightKg,
shippingCost,
notes,
invoiceId: invoiceId || null,
expenseId: expenseId || null,
partyId: partyId || null,
updatedAt: new Date()
})
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)));
const [pkg] = await db
.select({ trackingNumber: packages.trackingNumber })
.from(packages)
.where(eq(packages.id, params.packageId))
.limit(1);
await logCompanyEvent(
params.companyId,
user.id,
'package_updated',
`Updated package details for ${pkg?.trackingNumber ?? params.packageId}`,
{ packageId: params.packageId }
);
return { saved: true };
},
addCustomsInvoice: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'user',
'manager',
'admin',
'hr'
]);
const formData = await request.formData();
const partyId = formData.get('partyId')?.toString();
const amount = parseFloat(formData.get('amount')?.toString() || '0');
const currency = formData.get('currency')?.toString().trim() || 'THB';
const notes = formData.get('notes')?.toString().trim() || null;
const markPaid = formData.get('markPaid') === 'on';
const issueDate =
formData.get('issueDate')?.toString() || new Date().toISOString().split('T')[0];
if (!partyId) return fail(400, { customsError: 'Party (courier/agent) is required' });
if (isNaN(amount) || amount <= 0) {
return fail(400, { customsError: 'Amount must be a positive number' });
}
// Verify package exists and isn't already linked
const [pkg] = await db
.select({
trackingNumber: packages.trackingNumber,
customsInvoiceId: packages.customsInvoiceId,
carrier: packages.carrier
})
.from(packages)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) return fail(404, { customsError: 'Package not found' });
if (pkg.customsInvoiceId) {
return fail(400, { customsError: 'A customs invoice is already linked to this package' });
}
// Auto-generate invoice number — count existing incoming invoices this year
const year = new Date().getFullYear();
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(
and(eq(invoices.companyId, params.companyId), eq(invoices.direction, 'incoming'))
);
const invoiceNumber = `CUSTOMS-${year}-${String(count + 1).padStart(4, '0')}`;
// Insert the customs invoice
const [invoice] = await db
.insert(invoices)
.values({
companyId: params.companyId,
partyId,
direction: 'incoming',
invoiceNumber,
issueDate,
dueDate: null,
subtotal: amount.toFixed(2),
vat: '0',
total: amount.toFixed(2),
currency,
status: markPaid ? 'paid' : 'draft',
notes
})
.returning({ id: invoices.id });
await db.insert(invoiceLineItems).values({
invoiceId: invoice.id,
description: `Import duty / customs for package ${pkg.trackingNumber}`,
quantity: '1',
unitPrice: amount.toFixed(2),
total: amount.toFixed(2)
});
// Link to the package
await db
.update(packages)
.set({ customsInvoiceId: invoice.id, updatedAt: new Date() })
.where(eq(packages.id, params.packageId));
await logCompanyEvent(
params.companyId,
user.id,
'invoice_created',
`Customs invoice ${invoiceNumber} created for package ${pkg.trackingNumber} (${currency} ${amount.toFixed(2)})`,
{ invoiceId: invoice.id, packageId: params.packageId, amount: amount.toFixed(2) }
);
if (markPaid) {
await logCompanyEvent(
params.companyId,
user.id,
'invoice_paid',
`Customs invoice ${invoiceNumber} marked paid`,
{ invoiceId: invoice.id }
);
}
return { customsCreated: true, invoiceId: invoice.id };
},
deletePackage: async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const [pkg] = await db
.select({ trackingNumber: packages.trackingNumber })
.from(packages)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)))
.limit(1);
if (!pkg) return fail(404, { error: 'Package not found' });
await db
.delete(packages)
.where(and(eq(packages.id, params.packageId), eq(packages.companyId, params.companyId)));
redirect(303, `/companies/${params.companyId}/packages`);
}
};
@@ -0,0 +1,690 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate, formatDateTime, timeAgo } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const pkg = $derived(data.package);
const CARRIER_LABELS: Record<string, string> = {
ups: 'UPS',
fedex: 'FedEx',
dhl: 'DHL',
usps: 'USPS',
flash_express: 'Flash Express',
kerry_th: 'Kerry Express (TH)',
jnt_express: 'J&T Express',
thailand_post: 'Thailand Post',
other: 'Other'
};
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
in_transit: 'In Transit',
out_for_delivery: 'Out for Delivery',
delivered: 'Delivered',
exception: 'Exception',
returned: 'Returned',
cancelled: 'Cancelled'
};
const STATUSES = Object.entries(STATUS_LABELS);
const CARRIERS = [
{ value: 'ups', label: 'UPS' },
{ value: 'fedex', label: 'FedEx' },
{ value: 'dhl', label: 'DHL' },
{ value: 'usps', label: 'USPS' },
{ value: 'flash_express', label: 'Flash Express' },
{ value: 'kerry_th', label: 'Kerry Express (TH)' },
{ value: 'jnt_express', label: 'J&T Express' },
{ value: 'thailand_post', label: 'Thailand Post' },
{ value: 'other', label: 'Other' }
];
const CURRENCIES = ['THB', 'USD', 'EUR', 'GBP', 'SGD', 'CNY', 'JPY'];
function statusBadge(status: string): string {
const map: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
in_transit: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
out_for_delivery: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
delivered: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
exception: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
returned: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
};
return map[status] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
function statusDot(status: string): string {
const map: Record<string, string> = {
pending: 'bg-gray-400',
in_transit: 'bg-blue-500',
out_for_delivery: 'bg-amber-500',
delivered: 'bg-green-500',
exception: 'bg-red-500',
returned: 'bg-amber-400',
cancelled: 'bg-gray-400'
};
return map[status] ?? 'bg-gray-400';
}
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
let showEditModal = $state(false);
let confirmDelete = $state(false);
let showCustomsForm = $state(false);
// Manual update form state
let manualStatus = $state('in_transit');
let manualLocation = $state('');
let manualDescription = $state('');
const inputClass =
'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none';
const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
</script>
<svelte:head>
<title>{pkg.trackingNumber} - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<!-- Header row -->
<div class="flex flex-wrap items-center gap-3">
<a
href="/companies/{data.company.id}/packages"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
← Packages
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="font-mono text-lg font-bold text-gray-900 dark:text-white">{pkg.trackingNumber}</h2>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{CARRIER_LABELS[pkg.carrier] ?? pkg.carrier}
</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(pkg.status)}">
{STATUS_LABELS[pkg.status] ?? pkg.status}
</span>
{#if pkg.direction === 'incoming'}
<span class="text-blue-500 dark:text-blue-400 text-sm font-medium">↓ Incoming</span>
{:else}
<span class="text-orange-500 dark:text-orange-400 text-sm font-medium">↑ Outgoing</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Refresh Status -->
<form method="POST" action="?/refreshStatus" use:enhance>
<button
type="submit"
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Refresh Status
</button>
</form>
<!-- Edit -->
<button
type="button"
onclick={() => (showEditModal = true)}
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Edit
</button>
<!-- Delete (manager+) -->
{#if canManage}
{#if !confirmDelete}
<button
type="button"
onclick={() => (confirmDelete = true)}
class="rounded-md px-3 py-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</button>
{:else}
<form method="POST" action="?/deletePackage" use:enhance class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Are you sure?</span>
<button
type="submit"
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Yes, delete
</button>
<button
type="button"
onclick={() => (confirmDelete = false)}
class="text-sm text-gray-500 dark:text-gray-400 hover:underline"
>
Cancel
</button>
</form>
{/if}
{/if}
</div>
</div>
<!-- Refresh error banner -->
{#if form?.refreshError}
<div
class="rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
Refresh failed: {form.refreshError}
</div>
{/if}
{#if form?.refreshed}
<div
class="rounded-md bg-green-50 dark:bg-green-900/30 px-4 py-3 text-sm text-green-700 dark:text-green-300"
>
Status refreshed — {form.newCount} new event(s) added.
</div>
{/if}
{#if form?.updated}
<div
class="rounded-md bg-green-50 dark:bg-green-900/30 px-4 py-3 text-sm text-green-700 dark:text-green-300"
>
Package status updated.
</div>
{/if}
{#if form?.saved}
<div
class="rounded-md bg-green-50 dark:bg-green-900/30 px-4 py-3 text-sm text-green-700 dark:text-green-300"
>
Package details saved.
</div>
{/if}
<!-- Summary grid -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{#each [
{ label: 'Direction', value: pkg.direction === 'incoming' ? '↓ Incoming' : '↑ Outgoing' },
{ label: 'Shipped', value: pkg.shippedAt ? formatDateTime(pkg.shippedAt) : '—' },
{ label: 'ETA', value: pkg.estimatedDelivery ? formatDate(pkg.estimatedDelivery) : '—' },
...(pkg.deliveredAt ? [{ label: 'Delivered', value: formatDateTime(pkg.deliveredAt) }] : []),
{ label: 'Weight', value: pkg.weightKg ? `${parseFloat(pkg.weightKg).toFixed(3)} kg` : '—' },
{ label: 'Cost', value: pkg.shippingCost ? formatCurrency(pkg.shippingCost, pkg.currency) : '—' },
{ label: 'Location', value: pkg.currentLocation ?? '—' },
{ label: 'Last Refreshed', value: pkg.lastRefreshedAt ? timeAgo(pkg.lastRefreshedAt) : 'Never' }
] as card}
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"
>
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{card.label}
</p>
<p class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{card.value}</p>
</div>
{/each}
</div>
<!-- Linked card -->
{#if data.linkedInvoice || data.linkedExpense || data.linkedParty || data.customsInvoice}
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 flex flex-wrap gap-4 text-sm"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 w-full">
Linked To
</p>
{#if data.linkedInvoice}
<div>
<span class="text-gray-500 dark:text-gray-400">Invoice: </span>
<a
href="/companies/{data.company.id}/invoices/{data.linkedInvoice.id}"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
#{data.linkedInvoice.invoiceNumber}
</a>
</div>
{/if}
{#if data.customsInvoice}
<div>
<span class="text-gray-500 dark:text-gray-400">Customs: </span>
<a
href="/companies/{data.company.id}/invoices/{data.customsInvoice.id}"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
#{data.customsInvoice.invoiceNumber}
</a>
<span class="ml-1 text-gray-500 dark:text-gray-400">
({data.customsInvoice.currency} {data.customsInvoice.total})
</span>
</div>
{/if}
{#if data.linkedExpense}
<div>
<span class="text-gray-500 dark:text-gray-400">Expense: </span>
<span class="font-medium text-gray-900 dark:text-white">{data.linkedExpense.title}</span>
</div>
{/if}
{#if data.linkedParty}
<div>
<span class="text-gray-500 dark:text-gray-400">Party: </span>
<span class="font-medium text-gray-900 dark:text-white">{data.linkedParty.name}</span>
</div>
{/if}
</div>
{/if}
<!-- Customs / Import duty (after delivery, only if not yet recorded) -->
{#if pkg.status === 'delivered' && !data.customsInvoice}
<div class="rounded-lg border border-amber-200 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-900/20 p-5">
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="font-medium text-amber-900 dark:text-amber-200">Import Duty / Customs</h3>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300">
Did you pay customs duty or fees on delivery? Record it as a separate invoice.
</p>
</div>
{#if !showCustomsForm}
<button
type="button"
onclick={() => (showCustomsForm = true)}
class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
>
+ Record customs duty
</button>
{/if}
</div>
{#if form?.customsError}
<div class="mb-3 rounded-md bg-red-50 dark:bg-red-900/30 p-2 text-sm text-red-700 dark:text-red-300">
{form.customsError}
</div>
{/if}
{#if showCustomsForm}
<form
method="POST"
action="?/addCustomsInvoice"
use:enhance={() =>
async ({ update }) => {
await update();
}}
class="space-y-3"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label for="customsParty" class="mb-1 block text-xs font-medium text-amber-900 dark:text-amber-200">
Paid to (party) <span class="text-red-500">*</span>
</label>
<select
id="customsParty"
name="partyId"
required
class="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
>
<option value="">Select party…</option>
{#each data.parties as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="customsAmount" class="mb-1 block text-xs font-medium text-amber-900 dark:text-amber-200">
Amount <span class="text-red-500">*</span>
</label>
<input
type="number"
id="customsAmount"
name="amount"
step="0.01"
min="0.01"
required
class="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
/>
</div>
<div>
<label for="customsCurrency" class="mb-1 block text-xs font-medium text-amber-900 dark:text-amber-200">
Currency
</label>
<select
id="customsCurrency"
name="currency"
class="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
>
<option value="THB" selected>THB</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div>
<label for="customsIssueDate" class="mb-1 block text-xs font-medium text-amber-900 dark:text-amber-200">
Date paid
</label>
<input
type="date"
id="customsIssueDate"
name="issueDate"
value={new Date().toISOString().split('T')[0]}
class="rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
/>
</div>
<div>
<label for="customsNotes" class="mb-1 block text-xs font-medium text-amber-900 dark:text-amber-200">
Notes
</label>
<textarea
id="customsNotes"
name="notes"
rows="2"
class="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
></textarea>
</div>
<label class="flex items-center gap-2 text-sm text-amber-900 dark:text-amber-200">
<input type="checkbox" name="markPaid" checked class="rounded" />
Mark as paid (paid directly to courier on delivery)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showCustomsForm = false)}
class="rounded-md px-3 py-1.5 text-sm text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/40"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
>
Create Invoice
</button>
</div>
</form>
{/if}
</div>
{/if}
<!-- Notes -->
{#if pkg.notes}
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 text-sm"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">
Notes
</p>
<p class="text-gray-700 dark:text-gray-300">{pkg.notes}</p>
</div>
{/if}
<!-- Manual update form -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Add Manual Update
</h3>
<form method="POST" action="?/updateManual" use:enhance class="flex flex-wrap gap-3 items-end">
<div class="min-w-[160px]">
<label for="manualStatus" class={labelClass}>Status <span class="text-red-500">*</span></label>
<select
id="manualStatus"
name="status"
bind:value={manualStatus}
class={inputClass}
>
{#each STATUSES as [val, lbl]}
<option value={val}>{lbl}</option>
{/each}
</select>
</div>
<div class="min-w-[200px]">
<label for="manualLocation" class={labelClass}>Location</label>
<input
id="manualLocation"
name="location"
type="text"
bind:value={manualLocation}
placeholder="e.g. Bangkok sorting facility"
class={inputClass}
/>
</div>
<div class="min-w-[240px]">
<label for="manualDesc" class={labelClass}>Description</label>
<input
id="manualDesc"
name="description"
type="text"
bind:value={manualDescription}
placeholder="What happened?"
class={inputClass}
/>
</div>
<div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Update
</button>
</div>
</form>
</div>
<!-- Timeline -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Timeline
</h3>
{#if data.events.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No events yet.</p>
{:else}
<ol class="relative border-l border-gray-200 dark:border-gray-700 ml-3 space-y-6">
{#each data.events as ev}
<li class="ml-6">
<span
class="absolute -left-1.5 mt-1.5 flex h-3 w-3 items-center justify-center rounded-full {ev.status ? statusDot(ev.status) : 'bg-gray-400'}"
></span>
<div class="flex flex-wrap items-baseline gap-2">
<time class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatDateTime(ev.occurredAt)}
</time>
{#if ev.status}
<span
class="rounded-full px-1.5 py-0.5 text-xs font-medium {statusBadge(ev.status)}"
>
{STATUS_LABELS[ev.status] ?? ev.status}
</span>
{/if}
</div>
{#if ev.description}
<p class="mt-0.5 text-sm text-gray-900 dark:text-white">{ev.description}</p>
{/if}
{#if ev.location}
<p class="text-xs text-gray-500 dark:text-gray-400">{ev.location}</p>
{/if}
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-600">
{ev.source === 'manual' ? 'manual' : `via ${CARRIER_LABELS[ev.source] ?? ev.source}`}
</p>
</li>
{/each}
</ol>
{/if}
</div>
</div>
<!-- Edit modal -->
{#if showEditModal}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/50"
role="button"
tabindex="-1"
aria-label="Close modal"
onclick={() => (showEditModal = false)}
onkeydown={(e) => e.key === 'Escape' && (showEditModal = false)}
></div>
<!-- Modal panel -->
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
aria-modal="true"
role="dialog"
>
<div
class="w-full max-w-2xl rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl overflow-y-auto max-h-[90vh]"
>
<div class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">Edit Package</h3>
<button
type="button"
onclick={() => (showEditModal = false)}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<form
method="POST"
action="?/updatePackage"
use:enhance={() => {
return ({ result }) => {
if (result.type === 'success') showEditModal = false;
};
}}
class="p-6 space-y-4"
>
<div class="grid grid-cols-2 gap-4">
<div>
<span class="{labelClass}">Direction (read-only)</span>
<p class="text-sm text-gray-900 dark:text-white py-2">
{pkg.direction === 'incoming' ? '↓ Incoming' : '↑ Outgoing'}
</p>
</div>
<div>
<label for="edit-recipientName" class={labelClass}>Recipient Name</label>
<input
id="edit-recipientName"
name="recipientName"
type="text"
value={pkg.recipientName ?? ''}
class={inputClass}
/>
</div>
</div>
<div>
<label for="edit-description" class={labelClass}>Description</label>
<input
id="edit-description"
name="description"
type="text"
value={pkg.description ?? ''}
class={inputClass}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="edit-estimatedDelivery" class={labelClass}>Estimated Delivery</label>
<input
id="edit-estimatedDelivery"
name="estimatedDelivery"
type="date"
value={pkg.estimatedDelivery ?? ''}
class={inputClass}
/>
</div>
<div>
<label for="edit-weightKg" class={labelClass}>Weight (kg)</label>
<input
id="edit-weightKg"
name="weightKg"
type="number"
min="0"
step="0.001"
value={pkg.weightKg ? parseFloat(pkg.weightKg) : ''}
class={inputClass}
/>
</div>
<div>
<label for="edit-shippingCost" class={labelClass}>Shipping Cost</label>
<input
id="edit-shippingCost"
name="shippingCost"
type="number"
min="0"
step="0.01"
value={pkg.shippingCost ? parseFloat(pkg.shippingCost) : ''}
class={inputClass}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Invoice link -->
<div>
<label for="edit-invoiceId" class={labelClass}>Invoice</label>
<select id="edit-invoiceId" name="invoiceId" class={inputClass}>
<option value="">None</option>
{#each data.invoices as inv}
<option value={inv.id} selected={inv.id === pkg.invoiceId}>
#{inv.invoiceNumber}{inv.partyName}
</option>
{/each}
</select>
</div>
<!-- Expense link -->
<div>
<label for="edit-expenseId" class={labelClass}>Expense</label>
<select id="edit-expenseId" name="expenseId" class={inputClass}>
<option value="">None</option>
{#each data.expenses as exp}
<option value={exp.id} selected={exp.id === pkg.expenseId}>
{exp.title}{exp.projectName}
</option>
{/each}
</select>
</div>
<!-- Party link -->
<div class="col-span-2">
<label for="edit-partyId" class={labelClass}>Party</label>
<select id="edit-partyId" name="partyId" class={inputClass}>
<option value="">None</option>
{#each data.parties as p}
<option value={p.id} selected={p.id === pkg.partyId}>
{p.name} ({p.type})
</option>
{/each}
</select>
</div>
</div>
<div>
<label for="edit-notes" class={labelClass}>Notes</label>
<textarea
id="edit-notes"
name="notes"
rows="3"
class={inputClass}
>{pkg.notes ?? ''}</textarea>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
onclick={() => (showEditModal = false)}
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,148 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
packages,
packageEvents,
invoices,
expenses,
parties,
projects
} from '$lib/server/db/schema.js';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['user', 'manager', 'admin', 'hr']);
const preInvoiceId = url.searchParams.get('invoiceId') || '';
const preExpenseId = url.searchParams.get('expenseId') || '';
const preDirection = url.searchParams.get('direction') || 'outgoing';
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
partyName: parties.name
})
.from(invoices)
.innerJoin(parties, eq(invoices.partyId, parties.id))
.where(eq(invoices.companyId, params.companyId))
.orderBy(invoices.invoiceNumber);
const expenseList = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
projectName: projects.name
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId))
.orderBy(expenses.title);
const partyList = await db
.select({
id: parties.id,
name: parties.name,
type: parties.type
})
.from(parties)
.where(and(eq(parties.companyId, params.companyId), eq(parties.isActive, true), isNull(parties.deletedAt)))
.orderBy(parties.name);
return {
invoices: invoiceList,
expenses: expenseList,
parties: partyList,
preInvoiceId,
preExpenseId,
preDirection
};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'user',
'manager',
'admin',
'hr'
]);
const formData = await request.formData();
const direction = formData.get('direction')?.toString() as 'incoming' | 'outgoing' | undefined;
const carrier = formData.get('carrier')?.toString();
const trackingNumber = formData.get('trackingNumber')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
const recipientName = formData.get('recipientName')?.toString().trim() || null;
const shippedAtRaw = formData.get('shippedAt')?.toString() || null;
const estimatedDelivery = formData.get('estimatedDelivery')?.toString() || null;
const weightKgRaw = formData.get('weightKg')?.toString() || null;
const shippingCostRaw = formData.get('shippingCost')?.toString() || null;
const currency = formData.get('currency')?.toString() || 'THB';
const invoiceId = formData.get('invoiceId')?.toString() || null;
const expenseId = formData.get('expenseId')?.toString() || null;
const partyId = formData.get('partyId')?.toString() || null;
const notes = formData.get('notes')?.toString().trim() || null;
if (!direction || !['incoming', 'outgoing'].includes(direction)) {
return fail(400, { error: 'Direction is required' });
}
if (!carrier) {
return fail(400, { error: 'Carrier is required' });
}
if (!trackingNumber) {
return fail(400, { error: 'Tracking number is required' });
}
const shippedAt = shippedAtRaw ? new Date(shippedAtRaw) : null;
const weightKg = weightKgRaw ? parseFloat(weightKgRaw).toFixed(3) : null;
const shippingCost = shippingCostRaw ? parseFloat(shippingCostRaw).toFixed(2) : null;
const [newPkg] = await db
.insert(packages)
.values({
companyId: params.companyId,
direction,
carrier: carrier as any,
trackingNumber,
description,
recipientName,
shippedAt,
estimatedDelivery: estimatedDelivery || null,
weightKg,
shippingCost,
currency,
invoiceId: invoiceId || null,
expenseId: expenseId || null,
partyId: partyId || null,
notes,
status: 'pending',
createdBy: user.id
})
.returning({ id: packages.id });
await db.insert(packageEvents).values({
packageId: newPkg.id,
occurredAt: shippedAt ?? new Date(),
status: 'pending',
source: 'manual',
description: 'Package created'
});
await logCompanyEvent(
params.companyId,
user.id,
'package_created',
`Created ${direction} package ${trackingNumber} via ${carrier}`,
{ trackingNumber, carrier, direction }
);
redirect(303, `/companies/${params.companyId}/packages/${newPkg.id}`);
}
};
@@ -0,0 +1,281 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const CARRIERS = [
{ value: 'ups', label: 'UPS' },
{ value: 'fedex', label: 'FedEx' },
{ value: 'dhl', label: 'DHL' },
{ value: 'usps', label: 'USPS' },
{ value: 'flash_express', label: 'Flash Express' },
{ value: 'kerry_th', label: 'Kerry Express (TH)' },
{ value: 'jnt_express', label: 'J&T Express' },
{ value: 'thailand_post', label: 'Thailand Post' },
{ value: 'other', label: 'Other' }
];
const CURRENCIES = ['THB', 'USD', 'EUR', 'GBP', 'SGD', 'CNY', 'JPY'];
// svelte-ignore state_referenced_locally
let direction = $state(
(data.preDirection as 'incoming' | 'outgoing') === 'incoming' ? 'incoming' : 'outgoing'
);
// svelte-ignore state_referenced_locally
let selectedInvoiceId = $state(data.preInvoiceId);
// Auto-set direction based on linked invoice
$effect(() => {
if (selectedInvoiceId) {
const inv = data.invoices.find((i) => i.id === selectedInvoiceId);
if (inv) {
direction = inv.direction;
}
}
});
const inputClass =
'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none';
const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
</script>
<svelte:head>
<title>New Package - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<div class="mb-6 flex items-center gap-3">
<a
href="../packages"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
← Packages
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">New Package</h2>
</div>
{#if form?.error}
<div
class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{form.error}
</div>
{/if}
<form method="POST" class="space-y-6">
<!-- Shipment section -->
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4"
>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Shipment
</h3>
<!-- Direction -->
<fieldset>
<legend class="{labelClass}">Direction</legend>
<div class="flex gap-6">
{#each [{ value: 'outgoing', label: '↑ Outgoing (we send)' }, { value: 'incoming', label: '↓ Incoming (we receive)' }] as d}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="direction"
value={d.value}
checked={direction === d.value}
onchange={() => {
direction = d.value as 'incoming' | 'outgoing';
}}
class="text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{d.label}</span>
</label>
{/each}
</div>
</fieldset>
<div class="grid grid-cols-2 gap-4">
<!-- Carrier -->
<div>
<label for="carrier" class={labelClass}>Carrier <span class="text-red-500">*</span></label>
<select id="carrier" name="carrier" required class={inputClass}>
<option value="">Select carrier...</option>
{#each CARRIERS as c}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</div>
<!-- Tracking Number -->
<div>
<label for="trackingNumber" class={labelClass}
>Tracking Number <span class="text-red-500">*</span></label
>
<input
id="trackingNumber"
name="trackingNumber"
type="text"
required
placeholder="e.g. 1Z999AA10123456784"
class="{inputClass} font-mono"
/>
</div>
</div>
<!-- Description -->
<div>
<label for="description" class={labelClass}>Description</label>
<input
id="description"
name="description"
type="text"
placeholder="What's being shipped?"
class={inputClass}
/>
</div>
<!-- Recipient -->
<div>
<label for="recipientName" class={labelClass}>Recipient Name</label>
<input id="recipientName" name="recipientName" type="text" class={inputClass} />
</div>
</div>
<!-- Link To section -->
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4"
>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Link To
</h3>
<!-- Party -->
<div>
<label for="partyId" class={labelClass}>Party (optional)</label>
<select id="partyId" name="partyId" class={inputClass}>
<option value="">None</option>
{#each data.parties as p}
<option value={p.id}>{p.name} ({p.type})</option>
{/each}
</select>
</div>
<!-- Invoice -->
<div>
<label for="invoiceId" class={labelClass}>Invoice (optional)</label>
<select
id="invoiceId"
name="invoiceId"
bind:value={selectedInvoiceId}
class={inputClass}
>
<option value="">None</option>
{#each data.invoices as inv}
<option value={inv.id}
>#{inv.invoiceNumber}{inv.partyName} ({inv.direction})</option
>
{/each}
</select>
</div>
<!-- Expense -->
<div>
<label for="expenseId" class={labelClass}>Expense (optional)</label>
<select id="expenseId" name="expenseId" class={inputClass}>
<option value="">None</option>
{#each data.expenses as exp}
<option
value={exp.id}
selected={exp.id === data.preExpenseId}
>
{exp.title}{exp.projectName}
</option>
{/each}
</select>
</div>
</div>
<!-- Dates & Cost section -->
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4"
>
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Dates & Cost
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="shippedAt" class={labelClass}>Shipped Date</label>
<input id="shippedAt" name="shippedAt" type="date" class={inputClass} />
</div>
<div>
<label for="estimatedDelivery" class={labelClass}>Estimated Delivery</label>
<input id="estimatedDelivery" name="estimatedDelivery" type="date" class={inputClass} />
</div>
<div>
<label for="weightKg" class={labelClass}>Weight (kg)</label>
<input
id="weightKg"
name="weightKg"
type="number"
min="0"
step="0.001"
class={inputClass}
/>
</div>
<div>
<label for="shippingCost" class={labelClass}>Shipping Cost</label>
<input
id="shippingCost"
name="shippingCost"
type="number"
min="0"
step="0.01"
class={inputClass}
/>
</div>
<div>
<label for="currency" class={labelClass}>Currency</label>
<select id="currency" name="currency" class={inputClass}>
{#each CURRENCIES as cur}
<option value={cur} selected={cur === (data.company.currency ?? 'THB')}>{cur}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Notes section -->
<div
class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6"
>
<label for="notes" class="{labelClass} mb-2">Notes</label>
<textarea
id="notes"
name="notes"
rows="3"
class={inputClass}
placeholder="Any additional notes about this shipment..."
></textarea>
</div>
<div class="flex justify-end gap-3">
<a
href="../packages"
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Package
</button>
</div>
</form>
</div>
@@ -0,0 +1,38 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { parties } from '$lib/server/db/schema.js';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
export const load: PageServerLoad = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
const typeFilter = url.searchParams.get('type') || 'all';
const whereClause =
typeFilter === 'all'
? and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt))
: and(
eq(parties.companyId, params.companyId),
isNull(parties.deletedAt),
eq(parties.type, typeFilter as 'customer' | 'supplier' | 'both')
);
const partyList = await db
.select({
id: parties.id,
name: parties.name,
type: parties.type,
contactPerson: parties.contactPerson,
email: parties.email,
phone: parties.phone,
taxId: parties.taxId,
isActive: parties.isActive,
createdAt: parties.createdAt
})
.from(parties)
.where(whereClause)
.orderBy(sql`${parties.name} asc`);
return { parties: partyList, typeFilter };
};
@@ -0,0 +1,106 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageData } from './$types';
let { data } = $props();
const canManage = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager'));
const typeColors: Record<string, string> = {
customer: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
supplier: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
both: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
};
</script>
<svelte:head>
<title>Contacts - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Contacts
<span class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">({data.parties.length})</span>
</h2>
{#if canManage}
<a
href="./parties/new"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
New Contact
</a>
{/if}
</div>
<!-- Filter pills -->
<div class="mb-4 flex gap-2">
{#each ['all', 'customer', 'supplier'] as t}
<a
href="?type={t}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{data.typeFilter === t
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}"
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</a>
{/each}
</div>
{#if data.parties.length === 0}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No contacts found.</p>
{#if canManage}
<a href="./parties/new" class="mt-3 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">
Add your first contact
</a>
{/if}
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Name</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Type</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Contact</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Email</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Phone</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Tax ID</th>
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{#each data.parties as party}
<tr
class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
onclick={() => goto(`./parties/${party.id}`)}
>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{party.name}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {typeColors[party.type]}">
{party.type}
</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{party.contactPerson ?? '—'}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{party.email ?? '—'}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{party.phone ?? '—'}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">{party.taxId ?? '—'}</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{party.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'}"
>
{party.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,115 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { parties, invoices, expenses, projects } from '$lib/server/db/schema.js';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'user']);
const [party] = await db
.select()
.from(parties)
.where(
and(
eq(parties.id, params.partyId),
eq(parties.companyId, params.companyId),
isNull(parties.deletedAt)
)
)
.limit(1);
if (!party) error(404, 'Contact not found');
const relatedInvoices = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
total: invoices.total,
currency: invoices.currency
})
.from(invoices)
.where(
and(eq(invoices.partyId, params.partyId), eq(invoices.companyId, params.companyId))
)
.orderBy(sql`${invoices.issueDate} desc`)
.limit(20);
const relatedExpenses = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
currency: expenses.currency,
status: expenses.status,
expenseDate: expenses.expenseDate
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(expenses.partyId, params.partyId), eq(projects.companyId, params.companyId))
)
.orderBy(sql`${expenses.expenseDate} desc`)
.limit(20);
return { party, relatedInvoices, relatedExpenses };
};
export const actions: Actions = {
updateParty: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
if (!name) return fail(400, { error: 'Name is required' });
const type = (formData.get('type')?.toString() || 'customer') as 'customer' | 'supplier' | 'both';
await db
.update(parties)
.set({
name,
type,
contactPerson: formData.get('contactPerson')?.toString().trim() || null,
email: formData.get('email')?.toString().trim() || null,
phone: formData.get('phone')?.toString().trim() || null,
website: formData.get('website')?.toString().trim() || null,
taxId: formData.get('taxId')?.toString().trim() || null,
addressLine1: formData.get('addressLine1')?.toString().trim() || null,
addressLine2: formData.get('addressLine2')?.toString().trim() || null,
city: formData.get('city')?.toString().trim() || null,
postalCode: formData.get('postalCode')?.toString().trim() || null,
country: formData.get('country')?.toString().trim() || null,
paymentTerms: formData.get('paymentTerms')?.toString().trim() || null,
notes: formData.get('notes')?.toString().trim() || null,
updatedAt: new Date()
})
.where(
and(eq(parties.id, params.partyId), eq(parties.companyId, params.companyId))
);
return { success: true };
},
archiveParty: async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
await db
.update(parties)
.set({
deletedAt: new Date(),
isActive: false,
updatedAt: new Date()
})
.where(
and(eq(parties.id, params.partyId), eq(parties.companyId, params.companyId))
);
redirect(303, `/companies/${params.companyId}/parties`);
}
};
@@ -0,0 +1,321 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const canManage = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
);
let editing = $state(false);
const typeColors: Record<string, string> = {
customer: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
supplier: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
both: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
};
const statusColors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
};
</script>
<svelte:head>
<title>{data.party.name} - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center gap-3">
<a href="../parties" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
← Contacts
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.party.name}</h2>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {typeColors[data.party.type]}">
{data.party.type}
</span>
</div>
{#if form?.error}
<div class="rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- Details card -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
{#if !editing}
<div class="flex items-start justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Details</h3>
{#if canManage}
<button
onclick={() => (editing = true)}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Edit
</button>
{/if}
</div>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Contact Person</dt>
<dd class="text-gray-900 dark:text-white">{data.party.contactPerson ?? '—'}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Email</dt>
<dd class="text-gray-900 dark:text-white">{data.party.email ?? '—'}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Phone</dt>
<dd class="text-gray-900 dark:text-white">{data.party.phone ?? '—'}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Website</dt>
<dd class="text-gray-900 dark:text-white">{data.party.website ?? '—'}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Tax ID</dt>
<dd class="text-gray-900 dark:text-white">{data.party.taxId ?? '—'}</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Payment Terms</dt>
<dd class="text-gray-900 dark:text-white">{data.party.paymentTerms ?? '—'}</dd>
</div>
<div class="col-span-2">
<dt class="text-gray-500 dark:text-gray-400">Address</dt>
<dd class="text-gray-900 dark:text-white">
{#if data.party.addressLine1}
{data.party.addressLine1}{#if data.party.addressLine2}, {data.party.addressLine2}{/if}
{#if data.party.city || data.party.postalCode}
<br />{data.party.city ?? ''} {data.party.postalCode ?? ''}
{/if}
{#if data.party.country}<br />{data.party.country}{/if}
{:else}
{/if}
</dd>
</div>
{#if data.party.notes}
<div class="col-span-2">
<dt class="text-gray-500 dark:text-gray-400">Notes</dt>
<dd class="text-gray-900 dark:text-white whitespace-pre-wrap">{data.party.notes}</dd>
</div>
{/if}
</dl>
{#if canManage}
<div class="mt-6 border-t border-gray-100 dark:border-gray-700 pt-4">
<form method="POST" action="?/archiveParty" use:enhance>
<button
type="submit"
class="text-sm text-red-600 dark:text-red-400 hover:underline"
onclick={(e) => {
if (!confirm('Archive this contact? It will be hidden from lists.')) e.preventDefault();
}}
>
Archive Contact
</button>
</form>
</div>
{/if}
{:else}
<!-- Edit form -->
<form method="POST" action="?/updateParty" use:enhance class="space-y-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Edit Details</h3>
<button
type="button"
onclick={() => (editing = false)}
class="text-sm text-gray-500 dark:text-gray-400 hover:underline"
>
Cancel
</button>
</div>
<div>
<label for="e-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
<input id="e-name" name="name" type="text" required value={data.party.name}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<select id="e-type" name="type"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none">
{#each ['customer', 'supplier', 'both'] as t}
<option value={t} selected={data.party.type === t}>{t}</option>
{/each}
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="e-contact" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contact Person</label>
<input id="e-contact" name="contactPerson" type="text" value={data.party.contactPerson ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input id="e-email" name="email" type="email" value={data.party.email ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input id="e-phone" name="phone" type="tel" value={data.party.phone ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-website" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Website</label>
<input id="e-website" name="website" type="url" value={data.party.website ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-taxid" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tax ID</label>
<input id="e-taxid" name="taxId" type="text" value={data.party.taxId ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Payment Terms</label>
<select id="e-terms" name="paymentTerms"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none">
<option value="">None</option>
{#each ['Net 0', 'Net 7', 'Net 15', 'Net 30', 'Net 60'] as t}
<option value={t} selected={data.party.paymentTerms === t}>{t}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="e-addr1" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 1</label>
<input id="e-addr1" name="addressLine1" type="text" value={data.party.addressLine1 ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-addr2" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input id="e-addr2" name="addressLine2" type="text" value={data.party.addressLine2 ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="e-city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<input id="e-city" name="city" type="text" value={data.party.city ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-postal" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label>
<input id="e-postal" name="postalCode" type="text" value={data.party.postalCode ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label for="e-country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country</label>
<input id="e-country" name="country" type="text" value={data.party.country ?? ''}
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
</div>
<div>
<label for="e-notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<textarea id="e-notes" name="notes" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
>{data.party.notes ?? ''}</textarea>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick={() => (editing = false)}
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Save Changes
</button>
</div>
</form>
{/if}
</div>
<!-- Related Invoices -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Invoices ({data.relatedInvoices.length})
</h3>
{#if canManage}
<a href="/companies/{data.company.id}/invoices/new?partyId={data.party.id}" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">
New Invoice
</a>
{/if}
</div>
{#if data.relatedInvoices.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No invoices yet.</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-700">
<th class="pb-2 text-left font-medium text-gray-500 dark:text-gray-400">Invoice #</th>
<th class="pb-2 text-left font-medium text-gray-500 dark:text-gray-400">Direction</th>
<th class="pb-2 text-left font-medium text-gray-500 dark:text-gray-400">Date</th>
<th class="pb-2 text-right font-medium text-gray-500 dark:text-gray-400">Total</th>
<th class="pb-2 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{#each data.relatedInvoices as inv}
<tr class="border-b border-gray-50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer"
onclick={() => goto(`../../invoices/${inv.id}`)}>
<td class="py-2 font-medium text-gray-900 dark:text-white">{inv.invoiceNumber}</td>
<td class="py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{inv.direction === 'outgoing'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'}">
{inv.direction}
</span>
</td>
<td class="py-2 text-gray-600 dark:text-gray-400">{formatDate(inv.issueDate)}</td>
<td class="py-2 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(inv.total, inv.currency)}</td>
<td class="py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusColors[inv.status]}">
{inv.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<!-- Related Expenses -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">
Expenses ({data.relatedExpenses.length})
</h3>
{#if data.relatedExpenses.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No expenses linked to this contact.</p>
{:else}
<div class="space-y-2">
{#each data.relatedExpenses as exp}
<div class="flex items-center justify-between rounded-md border border-gray-100 dark:border-gray-700 px-3 py-2">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{exp.title}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{formatDate(exp.expenseDate)}</p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-900 dark:text-white">{formatCurrency(exp.amount, exp.currency)}</p>
<span class="text-xs text-gray-500 dark:text-gray-400">{exp.status}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,67 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { parties } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
return {};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
if (!name) return fail(400, { error: 'Name is required' });
const type = (formData.get('type')?.toString() || 'customer') as 'customer' | 'supplier' | 'both';
const contactPerson = formData.get('contactPerson')?.toString().trim() || null;
const email = formData.get('email')?.toString().trim() || null;
const phone = formData.get('phone')?.toString().trim() || null;
const website = formData.get('website')?.toString().trim() || null;
const taxId = formData.get('taxId')?.toString().trim() || null;
const addressLine1 = formData.get('addressLine1')?.toString().trim() || null;
const addressLine2 = formData.get('addressLine2')?.toString().trim() || null;
const city = formData.get('city')?.toString().trim() || null;
const postalCode = formData.get('postalCode')?.toString().trim() || null;
const country = formData.get('country')?.toString().trim() || null;
const paymentTerms = formData.get('paymentTerms')?.toString().trim() || null;
const notes = formData.get('notes')?.toString().trim() || null;
const [party] = await db
.insert(parties)
.values({
companyId: params.companyId,
name,
type,
contactPerson,
email,
phone,
website,
taxId,
addressLine1,
addressLine2,
city,
postalCode,
country,
paymentTerms,
notes
})
.returning({ id: parties.id });
await logCompanyEvent(
params.companyId,
user.id,
'party_created',
`Created contact "${name}" (${type})`,
{ partyId: party.id }
);
redirect(303, `/companies/${params.companyId}/parties/${party.id}`);
}
};
@@ -0,0 +1,210 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
</script>
<svelte:head>
<title>New Contact - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<div class="mb-6 flex items-center gap-3">
<a href="../parties" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
← Contacts
</a>
<span class="text-gray-300 dark:text-gray-600">/</span>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">New Contact</h2>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{form.error}
</div>
{/if}
<form method="POST" class="space-y-6">
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Basic Info</h3>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name <span class="text-red-500">*</span>
</label>
<input
id="name"
name="name"
type="text"
required
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Company or individual name"
/>
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<select
id="type"
name="type"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="customer">Customer</option>
<option value="supplier">Supplier</option>
<option value="both">Both (Customer & Supplier)</option>
</select>
</div>
<div>
<label for="contactPerson" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Contact Person</label>
<input
id="contactPerson"
name="contactPerson"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Full name"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input
id="email"
name="email"
type="email"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input
id="phone"
name="phone"
type="tel"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="website" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Website</label>
<input
id="website"
name="website"
type="url"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="https://"
/>
</div>
<div>
<label for="taxId" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tax ID</label>
<input
id="taxId"
name="taxId"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Address</h3>
<div>
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 1</label>
<input
id="addressLine1"
name="addressLine1"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="addressLine2" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input
id="addressLine2"
name="addressLine2"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<input
id="city"
name="city"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="postalCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label>
<input
id="postalCode"
name="postalCode"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country</label>
<input
id="country"
name="country"
type="text"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Thailand"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Terms & Notes</h3>
<div>
<label for="paymentTerms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Payment Terms</label>
<select
id="paymentTerms"
name="paymentTerms"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">None</option>
<option value="Net 0">Net 0 (Due on receipt)</option>
<option value="Net 7">Net 7</option>
<option value="Net 15">Net 15</option>
<option value="Net 30">Net 30</option>
<option value="Net 60">Net 60</option>
</select>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<textarea
id="notes"
name="notes"
rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
></textarea>
</div>
</div>
<div class="flex justify-end gap-3">
<a
href="../parties"
class="rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Contact
</button>
</div>
</form>
</div>
@@ -4,7 +4,7 @@
import { budgetPercent, budgetColor } from '$lib/utils/budget.js';
let { data } = $props();
const currency = data.company.currency;
const currency = $derived(data.company.currency);
</script>
<svelte:head>
@@ -13,7 +13,7 @@
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Projects</h2>
{#if data.companyRole !== 'viewer'}
{#if data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
<a
href="/companies/{data.company.id}/projects/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@@ -3,8 +3,8 @@
import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props();
const currency = data.company.currency;
const canAddExpense = data.companyRole !== 'viewer';
const currency = $derived(data.company.currency);
const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr'));
</script>
<svelte:head>
@@ -85,7 +85,7 @@
{#if data.tags.length > 0}
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
<span class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</span>
<div class="flex flex-wrap gap-2">
{#each data.tags as tag}
<label class="flex items-center gap-1 rounded-md border border-gray-200 dark:border-gray-600 px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
@@ -98,12 +98,13 @@
{/if}
<div class="flex justify-end gap-2">
<a
href="javascript:history.back()"
<button
type="button"
onclick={() => history.back()}
class="rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</a>
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@@ -39,12 +39,13 @@
></textarea>
</div>
<div class="flex justify-end gap-2">
<a
href="javascript:history.back()"
<button
type="button"
onclick={() => history.back()}
class="rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Cancel
</a>
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@@ -4,9 +4,11 @@
import type { PageData } from './$types';
let { data } = $props();
const currency = data.company.currency;
const currency = $derived(data.company.currency);
// svelte-ignore state_referenced_locally
let from = $state(data.dateRange.from);
// svelte-ignore state_referenced_locally
let to = $state(data.dateRange.to);
function applyFilter() {
@@ -90,18 +92,22 @@
{#each data.byProject as project}
{@const allocated = parseFloat(project.allocated)}
{@const spent = parseFloat(project.spent)}
{@const pct = allocated > 0 ? Math.min((spent / allocated) * 100, 100) : 0}
{@const overspent = allocated > 0 && spent > allocated}
{@const noBudget = allocated <= 0 && spent > 0}
{@const pct = allocated > 0 ? Math.min((spent / allocated) * 100, 100) : noBudget ? 100 : 0}
<div>
<div class="flex justify-between text-sm">
<span class="font-medium dark:text-white">{project.projectName}</span>
<span class="dark:text-white">
<span class="{noBudget || overspent ? 'text-red-600 dark:text-red-400' : 'dark:text-white'}">
{formatCurrency(spent, currency)} / {formatCurrency(allocated, currency)}
{#if noBudget}<span class="ml-1 text-xs">(no budget)</span>{/if}
{#if overspent}<span class="ml-1 text-xs">(over)</span>{/if}
</span>
</div>
<div class="mt-1 flex gap-1">
<div class="h-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
class="h-full rounded-full {noBudget || overspent || pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
@@ -119,17 +125,17 @@
<p class="text-sm text-gray-500 dark:text-gray-400">No data for this period.</p>
{:else}
{@const maxVal = Math.max(...data.byMonth.map((m) => parseFloat(m.total)))}
<div class="flex items-end gap-2" style="height: 200px;">
<div class="flex gap-2" style="height: 240px;">
{#each data.byMonth as month}
{@const val = parseFloat(month.total)}
{@const height = maxVal > 0 ? (val / maxVal) * 100 : 0}
<div class="flex flex-1 flex-col items-center gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{formatCurrency(val, currency)}</span>
{@const heightPx = maxVal > 0 ? (val / maxVal) * 180 : 0}
<div class="flex flex-1 flex-col items-center justify-end gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatCurrency(val, currency)}</span>
<div
class="w-full rounded-t bg-blue-500"
style="height: {height}%"
class="w-full min-w-12 max-w-32 rounded-t bg-blue-500 transition-all"
style="height: {heightPx}px;"
></div>
<span class="text-xs text-gray-400 dark:text-gray-500">{month.month}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 mt-1">{month.month}</span>
</div>
{/each}
</div>
@@ -2,9 +2,10 @@ import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyMembers, companies, users } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { eq, and, isNull } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import type { CompanyRole } from '$lib/types/index.js';
import { ALL_ROLES } from '$lib/types/index.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
@@ -16,16 +17,36 @@ export const load: PageServerLoad = async ({ locals, params }) => {
userId: users.id,
email: users.email,
displayName: users.displayName,
role: companyMembers.role
roles: companyMembers.roles
})
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(eq(companyMembers.companyId, params.companyId))
.orderBy(users.email);
return { members };
// Suggestions for the "Add member" datalist: all active users
// who aren't already members of this company.
const memberUserIds = new Set(members.map((m) => m.userId));
const allUsers = await db
.select({ id: users.id, email: users.email, displayName: users.displayName })
.from(users)
.where(isNull(users.disabledAt))
.orderBy(users.email);
const suggestions = allUsers
.filter((u) => !memberUserIds.has(u.id))
.map((u) => ({ email: u.email, displayName: u.displayName }));
return { members, suggestions };
};
function sanitizeRoles(input: string[]): CompanyRole[] {
const valid = input.filter((r): r is CompanyRole =>
(ALL_ROLES as string[]).includes(r)
);
return Array.from(new Set(valid));
}
export const actions: Actions = {
updateCompany: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
@@ -51,9 +72,10 @@ export const actions: Actions = {
const formData = await request.formData();
const email = formData.get('email')?.toString().trim().toLowerCase();
const role = formData.get('role')?.toString() as CompanyRole;
const roles = sanitizeRoles(formData.getAll('roles').map((r) => r.toString()));
if (!email || !role) return fail(400, { error: 'Email and role are required' });
if (!email) return fail(400, { error: 'Email is required' });
if (roles.length === 0) return fail(400, { error: 'At least one role is required' });
const [targetUser] = await db
.select({ id: users.id, displayName: users.displayName })
@@ -74,29 +96,34 @@ export const actions: Actions = {
await db.insert(companyMembers).values({
userId: targetUser.id,
companyId: params.companyId,
role
roles
});
await logCompanyEvent(params.companyId, admin.id, 'member_added',
`Added ${targetUser.displayName ?? email} as ${role}`,
{ targetUserId: targetUser.id, email, role }
`Added ${targetUser.displayName ?? email} with roles: ${roles.join(', ')}`,
{ targetUserId: targetUser.id, email, roles }
);
return { success: true };
},
updateRole: async ({ request, locals, params }) => {
updateRoles: async ({ request, locals, params }) => {
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const memberId = formData.get('memberId')?.toString();
const role = formData.get('role')?.toString() as CompanyRole;
const roles = sanitizeRoles(formData.getAll('roles').map((r) => r.toString()));
if (!memberId || !role) return fail(400, { error: 'Member and role are required' });
if (!memberId) return fail(400, { error: 'Member is required' });
if (roles.length === 0) return fail(400, { error: 'At least one role is required' });
// Get member info for the log
const [member] = await db
.select({ userId: companyMembers.userId, oldRole: companyMembers.role, email: users.email, displayName: users.displayName })
.select({
userId: companyMembers.userId,
oldRoles: companyMembers.roles,
email: users.email,
displayName: users.displayName
})
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
@@ -104,13 +131,13 @@ export const actions: Actions = {
await db
.update(companyMembers)
.set({ role })
.set({ roles })
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)));
if (member) {
await logCompanyEvent(params.companyId, admin.id, 'member_role_changed',
`Changed ${member.displayName ?? member.email} role from ${member.oldRole} to ${role}`,
{ targetUserId: member.userId, oldRole: member.oldRole, newRole: role }
`Changed ${member.displayName ?? member.email} roles from [${member.oldRoles.join(', ')}] to [${roles.join(', ')}]`,
{ targetUserId: member.userId, oldRoles: member.oldRoles, newRoles: roles }
);
}
@@ -125,9 +152,13 @@ export const actions: Actions = {
if (!memberId) return fail(400, { error: 'Member ID required' });
// Get member info for the log
const [member] = await db
.select({ userId: companyMembers.userId, email: users.email, displayName: users.displayName, role: companyMembers.role })
.select({
userId: companyMembers.userId,
email: users.email,
displayName: users.displayName,
roles: companyMembers.roles
})
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
@@ -139,8 +170,8 @@ export const actions: Actions = {
if (member) {
await logCompanyEvent(params.companyId, admin.id, 'member_removed',
`Removed ${member.displayName ?? member.email} (was ${member.role})`,
{ targetUserId: member.userId, role: member.role }
`Removed ${member.displayName ?? member.email} (was ${member.roles.join(', ')})`,
{ targetUserId: member.userId, roles: member.roles }
);
}
@@ -3,7 +3,7 @@
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const isAdmin = data.companyRole === 'admin';
const isAdmin = $derived(data.companyRoles.includes('admin'));
</script>
<svelte:head>
@@ -53,13 +53,33 @@
</div>
{/if}
{#if isAdmin}
<!-- Shipping accounts link -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Shipping Accounts</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure carrier API credentials for package tracking.
</p>
</div>
<a
href="/companies/{data.company.id}/settings/shipping"
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Manage →
</a>
</div>
</div>
{/if}
<!-- Members -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Members</h2>
{#if isAdmin}
<form method="POST" action="?/addMember" use:enhance class="mb-4 flex items-end gap-3">
<div class="flex-1">
<form method="POST" action="?/addMember" use:enhance class="mb-4 space-y-3 rounded-md border border-gray-200 dark:border-gray-700 p-3">
<div>
<label for="email" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Add Member by Email</label>
<input
type="email"
@@ -67,20 +87,34 @@
name="email"
required
placeholder="user@example.com"
list="member-suggestions"
autocomplete="off"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm"
/>
<datalist id="member-suggestions">
{#each data.suggestions as s}
<option value={s.email} label={s.displayName ?? ''}></option>
{/each}
</datalist>
{#if data.suggestions.length === 0}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
No other registered users available.
</p>
{/if}
</div>
<div>
<span class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Roles (one or more)</span>
<div class="flex flex-wrap gap-3">
{#each ['admin', 'manager', 'hr', 'user', 'viewer'] as role}
<label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="roles" value={role} class="rounded" checked={role === 'viewer'} />
{role}
</label>
{/each}
</div>
<div class="w-32">
<label for="role" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Role</label>
<select id="role" name="role" class="w-full rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-3 py-2 text-sm">
<option value="viewer">Viewer</option>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add
Add Member
</button>
</form>
{/if}
@@ -90,7 +124,7 @@
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Role</th>
<th class="px-4 py-3 font-medium">Roles</th>
{#if isAdmin}
<th class="px-4 py-3 font-medium">Actions</th>
{/if}
@@ -99,24 +133,31 @@
<tbody>
{#each data.members as member}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-3">{member.displayName ?? '—'}</td>
<td class="px-4 py-3 dark:text-white">{member.displayName ?? '—'}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{member.email}</td>
<td class="px-4 py-3">
{#if isAdmin}
<form method="POST" action="?/updateRole" use:enhance class="inline">
<input type="hidden" name="memberId" value={member.id} />
<select
name="role"
onchange={(e) => e.currentTarget.form?.requestSubmit()}
class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-2 py-1 text-sm"
<form
method="POST"
action="?/updateRoles"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="flex flex-wrap items-center gap-2"
>
{#each ['viewer', 'user', 'manager', 'admin'] as role}
<option value={role} selected={member.role === role}>{role}</option>
<input type="hidden" name="memberId" value={member.id} />
{#each ['admin', 'manager', 'hr', 'user', 'viewer'] as role}
<label class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300">
<input type="checkbox" name="roles" value={role} checked={member.roles.includes(role as any)} class="rounded" />
{role}
</label>
{/each}
</select>
<button type="submit" class="rounded bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600">
Save
</button>
</form>
{:else}
{member.role}
<span class="dark:text-white">{member.roles.join(', ')}</span>
{/if}
</td>
{#if isAdmin}
@@ -0,0 +1,123 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { shippingAccounts } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { CARRIER_LABELS } from '$lib/server/shipping/index.js';
import type { Carrier } from '$lib/server/shipping/types.js';
const ALL_CARRIERS: Carrier[] = [
'ups',
'fedex',
'dhl',
'usps',
'flash_express',
'kerry_th',
'jnt_express',
'thailand_post',
'other'
];
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'admin');
const accounts = await db
.select()
.from(shippingAccounts)
.where(eq(shippingAccounts.companyId, params.companyId))
.orderBy(shippingAccounts.carrier);
return {
accounts: accounts.map((a) => ({
...a,
// Don't expose encrypted credentials to the client
credentialsEncrypted: a.credentialsEncrypted ? '***' : null
})),
carriers: ALL_CARRIERS.map((c) => ({ value: c, label: CARRIER_LABELS[c] }))
};
};
export const actions: Actions = {
add: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const carrier = formData.get('carrier')?.toString() as Carrier | undefined;
const displayName = formData.get('displayName')?.toString().trim() || null;
const credentialsJson = formData.get('credentialsJson')?.toString().trim() || '';
if (!carrier || !ALL_CARRIERS.includes(carrier)) {
return fail(400, { error: 'Carrier is required' });
}
if (credentialsJson) {
try {
JSON.parse(credentialsJson);
} catch {
return fail(400, { error: 'Credentials must be valid JSON' });
}
}
const existing = await db
.select({ id: shippingAccounts.id })
.from(shippingAccounts)
.where(and(eq(shippingAccounts.companyId, params.companyId), eq(shippingAccounts.carrier, carrier)))
.limit(1);
if (existing.length > 0) {
return fail(400, { error: `A shipping account already exists for ${CARRIER_LABELS[carrier]}` });
}
await db.insert(shippingAccounts).values({
companyId: params.companyId,
carrier,
displayName,
credentialsEncrypted: credentialsJson || null
});
await logCompanyEvent(
params.companyId,
user.id,
'shipping_account_added',
`Shipping account added for ${CARRIER_LABELS[carrier]}`,
{ carrier }
);
return { success: true };
},
remove: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const accountId = formData.get('accountId')?.toString();
if (!accountId) return fail(400, { error: 'Account ID required' });
const [account] = await db
.select({ carrier: shippingAccounts.carrier })
.from(shippingAccounts)
.where(
and(eq(shippingAccounts.id, accountId), eq(shippingAccounts.companyId, params.companyId))
)
.limit(1);
if (!account) return fail(404, { error: 'Account not found' });
await db
.delete(shippingAccounts)
.where(
and(eq(shippingAccounts.id, accountId), eq(shippingAccounts.companyId, params.companyId))
);
await logCompanyEvent(
params.companyId,
user.id,
'shipping_account_removed',
`Shipping account for ${CARRIER_LABELS[account.carrier as Carrier]} removed`,
{ carrier: account.carrier }
);
return { success: true };
}
};
@@ -0,0 +1,166 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDateTime } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
let showAdd = $state(false);
</script>
<svelte:head>
<title>Shipping Accounts - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<div class="mb-6 flex items-center justify-between">
<div>
<a
href="/companies/{data.company.id}/settings"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
← Settings
</a>
<h1 class="mt-2 text-2xl font-bold text-gray-900 dark:text-white">Shipping Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure carrier credentials so packages can auto-refresh their status.
</p>
</div>
<button
onclick={() => (showAdd = true)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Account
</button>
</div>
<div class="mb-4 rounded-md bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<strong>Experimental:</strong> carrier API integrations are currently stubbed. Credentials are stored unencrypted — do not add production keys yet.
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
{#if data.accounts.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No shipping accounts configured.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr class="text-left text-gray-500 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Carrier</th>
<th class="px-4 py-3 font-medium">Display Name</th>
<th class="px-4 py-3 font-medium">Credentials</th>
<th class="px-4 py-3 font-medium">Added</th>
<th class="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each data.accounts as acc}
{@const label = data.carriers.find((c) => c.value === acc.carrier)?.label ?? acc.carrier}
<tr class="border-t border-gray-100 dark:border-gray-700">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{label}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{acc.displayName ?? '—'}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">
{#if acc.credentialsEncrypted}
<span class="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/40 dark:text-green-300">
configured
</span>
{:else}
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
none
</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-400 dark:text-gray-500">{formatDateTime(acc.createdAt)}</td>
<td class="px-4 py-3">
<form method="POST" action="?/remove" use:enhance>
<input type="hidden" name="accountId" value={acc.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800">
Remove
</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if showAdd}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Shipping Account</h2>
<form
method="POST"
action="?/add"
use:enhance={() => async ({ update }) => {
await update();
showAdd = false;
}}
>
<div class="mb-4">
<label for="carrier" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Carrier
</label>
<select
id="carrier"
name="carrier"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">Select…</option>
{#each data.carriers as c}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</div>
<div class="mb-4">
<label for="displayName" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Display Name
</label>
<input
type="text"
id="displayName"
name="displayName"
placeholder="e.g. Main UPS account"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div class="mb-4">
<label for="credentialsJson" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Credentials (JSON)
</label>
<textarea
id="credentialsJson"
name="credentialsJson"
rows="4"
placeholder={'{"clientId": "...", "clientSecret": "..."}'}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Stored unencrypted — use only for testing.
</p>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showAdd = false)}
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add
</button>
</div>
</form>
</div>
</div>
{/if}
+37 -10
View File
@@ -4,27 +4,29 @@ import {
companyMembers,
companies,
projects,
expenses
expenses,
leaveRequests,
payslips,
invoices
} from '$lib/server/db/schema.js';
import { eq, and, sql, isNull } from 'drizzle-orm';
import { eq, and, sql, isNull, lt } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals }) => {
const userId = locals.user!.id;
const today = new Date().toISOString().split('T')[0];
// Get all companies the user belongs to with summary stats
const userCompanies = await db
.select({
id: companies.id,
name: companies.name,
totalBudget: companies.totalBudget,
currency: companies.currency,
role: companyMembers.role
roles: companyMembers.roles
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
.where(and(eq(companyMembers.userId, userId), isNull(companies.deletedAt)));
// For each company, get project count and pending expense count
const companySummaries = await Promise.all(
userCompanies.map(async (company) => {
const [projectCount] = await db
@@ -36,23 +38,48 @@ export const load: PageServerLoad = async ({ locals }) => {
.select({ count: sql<number>`count(*)::int` })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(projects.companyId, company.id), eq(expenses.status, 'pending'))
);
.where(and(eq(projects.companyId, company.id), eq(expenses.status, 'pending')));
const [approvedTotal] = await db
.select({ total: sql<string>`coalesce(sum(${expenses.amount}), 0)` })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(projects.companyId, company.id), eq(expenses.status, 'approved')));
const [pendingLeave] = await db
.select({ count: sql<number>`count(*)::int` })
.from(leaveRequests)
.where(
and(eq(projects.companyId, company.id), eq(expenses.status, 'approved'))
and(
eq(leaveRequests.companyId, company.id),
eq(leaveRequests.status, 'pending')
)
);
const [draftPayslips] = await db
.select({ count: sql<number>`count(*)::int` })
.from(payslips)
.where(and(eq(payslips.companyId, company.id), eq(payslips.status, 'draft')));
const [overdueInvoices] = await db
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(
and(
eq(invoices.companyId, company.id),
eq(invoices.status, 'sent'),
lt(invoices.dueDate, today)
)
);
return {
...company,
projectCount: projectCount.count,
pendingExpenses: pendingCount.count,
totalSpent: approvedTotal.total
totalSpent: approvedTotal.total,
pendingLeave: pendingLeave.count,
draftPayslips: draftPayslips.count,
overdueInvoices: overdueInvoices.count
};
})
);
+21 -3
View File
@@ -7,7 +7,7 @@
</script>
<svelte:head>
<title>Dashboard - B4L Budget</title>
<title>Dashboard - {data.appName}</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
@@ -45,7 +45,7 @@
<span
class="rounded-full bg-blue-100 dark:bg-blue-900/40 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300"
>
{company.role}
{company.roles.join(', ')}
</span>
</div>
@@ -64,10 +64,28 @@
</div>
{#if company.pendingExpenses > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Pending Approvals</span>
<span class="text-gray-500 dark:text-gray-400">Pending Expenses</span>
<span class="font-medium text-amber-600">{company.pendingExpenses}</span>
</div>
{/if}
{#if company.pendingLeave > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Pending Leave</span>
<span class="font-medium text-amber-600">{company.pendingLeave}</span>
</div>
{/if}
{#if company.draftPayslips > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Draft Payslips</span>
<span class="font-medium text-blue-600">{company.draftPayslips}</span>
</div>
{/if}
{#if company.overdueInvoices > 0}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Overdue Invoices</span>
<span class="font-medium text-red-600">{company.overdueInvoices}</span>
</div>
{/if}
</div>
<div class="mt-4">
+1 -1
View File
@@ -6,7 +6,7 @@
</script>
<svelte:head>
<title>Login - Buildfor Life Budget</title>
<title>Login - {data.appName}</title>
</svelte:head>
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
+3 -3
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import type { ActionData, PageData } from './$types';
let { form } = $props();
let { form, data } = $props();
</script>
<svelte:head>
<title>Sign Up - Buildfor Life Budget</title>
<title>Sign Up - {data.appName}</title>
</svelte:head>
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
+3 -1
View File
@@ -1,7 +1,9 @@
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
user: locals.user,
appName: env.APP_NAME || 'B4L Budget'
};
};
-15
View File
@@ -1,15 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props();
</script>
<svelte:head>
<title>Buildfor Life Budget</title>
</svelte:head>
{#if data.user}
<meta http-equiv="refresh" content="0; url=/dashboard" />
{:else}
<meta http-equiv="refresh" content="0; url=/login" />
{/if}