Major expansion: HR module, CRM, integrations, packages, validation pipeline
Validate / validate (push) Successful in 34s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
echo "Running validation before push (svelte-check + build)..."
|
||||
npm run validate
|
||||
Generated
+70
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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 & 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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
||||
+5
-4
@@ -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}
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user