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
|
HOST=127.0.0.1
|
||||||
ORIGIN=http://localhost:3000
|
ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Branding
|
||||||
|
APP_NAME=B4L Budget
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://budget_app:password@localhost:5432/buildfor_life_budget
|
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",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
"drizzle-kit": "^0.30.5",
|
"drizzle-kit": "^0.30.5",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"svelte": "^5.19.0",
|
"svelte": "^5.19.0",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
@@ -1297,6 +1300,33 @@
|
|||||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.3",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||||
@@ -2721,6 +2751,22 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"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": "^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": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
@@ -3121,6 +3173,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
|
|||||||
+8
-3
@@ -7,20 +7,24 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.38.4",
|
"drizzle-orm": "^0.38.4",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@@ -29,9 +33,10 @@
|
|||||||
"@sveltejs/kit": "^2.15.2",
|
"@sveltejs/kit": "^2.15.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/pg": "^8.11.11",
|
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
"drizzle-kit": "^0.30.5",
|
"drizzle-kit": "^0.30.5",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"svelte": "^5.19.0",
|
"svelte": "^5.19.0",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: { id: string; email: string; displayName: string | null; isSystemAdmin: boolean };
|
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;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { user, companies, open, onToggle }: Props = $props();
|
let { user, companies, appName, open, onToggle }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex h-14 items-center border-b border-gray-200 px-4 dark:border-gray-700">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
{company.companyName[0]?.toUpperCase()}
|
{company.companyName[0]?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="truncate">{company.companyName}</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>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,41 +19,89 @@ export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCompanyRole(
|
export async function getCompanyRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
companyId: string
|
companyId: string
|
||||||
): Promise<CompanyRole | null> {
|
): Promise<CompanyRole[] | null> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({ role: companyMembers.role })
|
.select({ roles: companyMembers.roles })
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
|
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (result.length === 0) return null;
|
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(
|
export async function requireCompanyRole(
|
||||||
locals: App.Locals,
|
locals: App.Locals,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
minRole: CompanyRole
|
minRole: Exclude<CompanyRole, 'hr'>
|
||||||
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
|
): Promise<{ user: NonNullable<App.Locals['user']>; roles: CompanyRole[] }> {
|
||||||
const user = requireAuth(locals);
|
const user = requireAuth(locals);
|
||||||
|
|
||||||
// System admins bypass company role checks
|
|
||||||
if (user.isSystemAdmin) {
|
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');
|
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`);
|
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 ──────────────────────────────────────────────
|
// ── 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']);
|
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||||
|
|
||||||
// ── Users ──────────────────────────────────────────────
|
// ── Users ──────────────────────────────────────────────
|
||||||
@@ -77,7 +77,7 @@ export const companyMembers = pgTable(
|
|||||||
companyId: uuid('company_id')
|
companyId: uuid('company_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
role: companyRoleEnum('role').notNull(),
|
roles: companyRoleEnum('roles').array().notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
|
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
|
||||||
@@ -124,6 +124,7 @@ export const expenses = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
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')
|
submittedBy: text('submitted_by')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
@@ -192,6 +193,422 @@ export const budgetAllocations = pgTable('budget_allocations', {
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
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) ──────────────────────────
|
// ── Company Log (Audit Trail) ──────────────────────────
|
||||||
|
|
||||||
export const companyLogEventEnum = pgEnum('company_log_event', [
|
export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||||
@@ -210,7 +627,32 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
|||||||
'expense_approved',
|
'expense_approved',
|
||||||
'expense_rejected',
|
'expense_rejected',
|
||||||
'category_created',
|
'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(
|
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 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,
|
admin: 4,
|
||||||
manager: 3,
|
manager: 3,
|
||||||
user: 2,
|
user: 2,
|
||||||
viewer: 1
|
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({
|
.select({
|
||||||
companyId: companies.id,
|
companyId: companies.id,
|
||||||
companyName: companies.name,
|
companyName: companies.name,
|
||||||
role: companyMembers.role
|
roles: companyMembers.roles
|
||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
user={data.user}
|
user={data.user}
|
||||||
companies={data.companies}
|
companies={data.companies}
|
||||||
|
appName={data.appName}
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onToggle={() => (sidebarOpen = !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">
|
<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
|
<button
|
||||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
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"
|
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">
|
<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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
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 { eq, and, isNull } from 'drizzle-orm';
|
||||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||||
import { requireAuth } from '$lib/server/authorization.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 }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const user = requireAuth(locals);
|
const user = requireAuth(locals);
|
||||||
@@ -16,7 +17,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
description: companies.description,
|
description: companies.description,
|
||||||
totalBudget: companies.totalBudget,
|
totalBudget: companies.totalBudget,
|
||||||
currency: companies.currency,
|
currency: companies.currency,
|
||||||
role: companyMembers.role
|
roles: companyMembers.roles
|
||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
@@ -49,9 +50,27 @@ export const actions: Actions = {
|
|||||||
await db.insert(companyMembers).values({
|
await db.insert(companyMembers).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
companyId: company.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 });
|
await logCompanyEvent(company.id, user.id, 'company_created', `Company "${name}" created`, { currency });
|
||||||
|
|
||||||
const budgetNum = parseFloat(totalBudget);
|
const budgetNum = parseFloat(totalBudget);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Companies - B4L Budget</title>
|
<title>Companies - {data.appName}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="mt-3 flex items-center justify-between text-sm">
|
<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="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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { companies } from '$lib/server/db/schema.js';
|
import { companies } from '$lib/server/db/schema.js';
|
||||||
import { eq, and, isNull } from 'drizzle-orm';
|
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 }) => {
|
export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||||
const user = requireAuth(locals);
|
const user = requireAuth(locals);
|
||||||
@@ -18,9 +19,11 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
|||||||
error(404, 'Company not found');
|
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');
|
error(403, 'Not a member of this company');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +35,6 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
|||||||
totalBudget: company.totalBudget,
|
totalBudget: company.totalBudget,
|
||||||
currency: company.currency
|
currency: company.currency
|
||||||
},
|
},
|
||||||
companyRole: role
|
companyRoles: roles
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,29 @@
|
|||||||
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
|
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
|
||||||
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
|
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
|
||||||
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' },
|
{ 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}/import`, label: 'Import' },
|
||||||
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' }
|
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.company.name} - B4L Budget</title>
|
<title>{data.company.name} - {data.appName}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<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="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">
|
<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>
|
<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
|
<a
|
||||||
href="/companies/{data.company.id}/projects/new"
|
href="/companies/{data.company.id}/projects/new"
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-700"
|
class="text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
const remaining = $derived(total - totalSpent);
|
const remaining = $derived(total - totalSpent);
|
||||||
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
||||||
const unallocated = $derived(total - data.totalAllocated);
|
const unallocated = $derived(total - data.totalAllocated);
|
||||||
const canAllocate = $derived(data.companyRole === 'admin' || data.companyRole === 'manager');
|
const canAllocate = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager'));
|
||||||
const isAdmin = $derived(data.companyRole === 'admin');
|
const isAdmin = $derived(data.companyRoles.includes('admin'));
|
||||||
|
|
||||||
let showAddBudget = $state(false);
|
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">
|
<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>
|
<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">
|
<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>
|
<label for="projectId" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Project</label>
|
||||||
<select
|
<select
|
||||||
id="projectId"
|
id="projectId"
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label for="amount" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Amount</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
placeholder="Negative to deallocate"
|
placeholder="Negative to deallocate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<label for="note" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Note</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const canManage = data.companyRole === 'admin' || data.companyRole === 'manager';
|
const canManage = $derived(
|
||||||
|
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const currency = data.company.currency;
|
const currency = $derived(data.company.currency);
|
||||||
const canApprove = data.companyRole === 'admin' || data.companyRole === 'manager';
|
const canApprove = $derived(
|
||||||
|
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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';
|
import { budgetPercent, budgetColor } from '$lib/utils/budget.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const currency = data.company.currency;
|
const currency = $derived(data.company.currency);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Projects</h2>
|
<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
|
<a
|
||||||
href="/companies/{data.company.id}/projects/new"
|
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"
|
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';
|
import { formatCurrency } from '$lib/utils/currency.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const currency = data.company.currency;
|
const currency = $derived(data.company.currency);
|
||||||
const canAddExpense = data.companyRole !== 'viewer';
|
const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
+5
-4
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
{#if data.tags.length > 0}
|
{#if data.tags.length > 0}
|
||||||
<div class="mb-4">
|
<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">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each data.tags as tag}
|
{#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">
|
<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}
|
{/if}
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<a
|
<button
|
||||||
href="javascript:history.back()"
|
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"
|
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
|
Cancel
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<a
|
<button
|
||||||
href="javascript:history.back()"
|
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"
|
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
|
Cancel
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
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';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data } = $props();
|
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);
|
let from = $state(data.dateRange.from);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let to = $state(data.dateRange.to);
|
let to = $state(data.dateRange.to);
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
@@ -90,18 +92,22 @@
|
|||||||
{#each data.byProject as project}
|
{#each data.byProject as project}
|
||||||
{@const allocated = parseFloat(project.allocated)}
|
{@const allocated = parseFloat(project.allocated)}
|
||||||
{@const spent = parseFloat(project.spent)}
|
{@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>
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="font-medium dark:text-white">{project.projectName}</span>
|
<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)}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex gap-1">
|
<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-3 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
<div
|
<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}%"
|
style="width: {pct}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,17 +125,17 @@
|
|||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No data for this period.</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">No data for this period.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{@const maxVal = Math.max(...data.byMonth.map((m) => parseFloat(m.total)))}
|
{@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}
|
{#each data.byMonth as month}
|
||||||
{@const val = parseFloat(month.total)}
|
{@const val = parseFloat(month.total)}
|
||||||
{@const height = maxVal > 0 ? (val / maxVal) * 100 : 0}
|
{@const heightPx = maxVal > 0 ? (val / maxVal) * 180 : 0}
|
||||||
<div class="flex flex-1 flex-col items-center gap-1">
|
<div class="flex flex-1 flex-col items-center justify-end gap-1">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{formatCurrency(val, currency)}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatCurrency(val, currency)}</span>
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-t bg-blue-500"
|
class="w-full min-w-12 max-w-32 rounded-t bg-blue-500 transition-all"
|
||||||
style="height: {height}%"
|
style="height: {heightPx}px;"
|
||||||
></div>
|
></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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { companyMembers, companies, users } from '$lib/server/db/schema.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 { requireCompanyRole } from '$lib/server/authorization.js';
|
||||||
import type { CompanyRole } from '$lib/types/index.js';
|
import type { CompanyRole } from '$lib/types/index.js';
|
||||||
|
import { ALL_ROLES } from '$lib/types/index.js';
|
||||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
@@ -16,16 +17,36 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
userId: users.id,
|
userId: users.id,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
role: companyMembers.role
|
roles: companyMembers.roles
|
||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(users, eq(companyMembers.userId, users.id))
|
.innerJoin(users, eq(companyMembers.userId, users.id))
|
||||||
.where(eq(companyMembers.companyId, params.companyId))
|
.where(eq(companyMembers.companyId, params.companyId))
|
||||||
.orderBy(users.email);
|
.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 = {
|
export const actions: Actions = {
|
||||||
updateCompany: async ({ request, locals, params }) => {
|
updateCompany: async ({ request, locals, params }) => {
|
||||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||||
@@ -51,9 +72,10 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const email = formData.get('email')?.toString().trim().toLowerCase();
|
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
|
const [targetUser] = await db
|
||||||
.select({ id: users.id, displayName: users.displayName })
|
.select({ id: users.id, displayName: users.displayName })
|
||||||
@@ -74,29 +96,34 @@ export const actions: Actions = {
|
|||||||
await db.insert(companyMembers).values({
|
await db.insert(companyMembers).values({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
role
|
roles
|
||||||
});
|
});
|
||||||
|
|
||||||
await logCompanyEvent(params.companyId, admin.id, 'member_added',
|
await logCompanyEvent(params.companyId, admin.id, 'member_added',
|
||||||
`Added ${targetUser.displayName ?? email} as ${role}`,
|
`Added ${targetUser.displayName ?? email} with roles: ${roles.join(', ')}`,
|
||||||
{ targetUserId: targetUser.id, email, role }
|
{ targetUserId: targetUser.id, email, roles }
|
||||||
);
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRole: async ({ request, locals, params }) => {
|
updateRoles: async ({ request, locals, params }) => {
|
||||||
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
|
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const memberId = formData.get('memberId')?.toString();
|
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
|
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)
|
.from(companyMembers)
|
||||||
.innerJoin(users, eq(companyMembers.userId, users.id))
|
.innerJoin(users, eq(companyMembers.userId, users.id))
|
||||||
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
|
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
|
||||||
@@ -104,13 +131,13 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
await db
|
await db
|
||||||
.update(companyMembers)
|
.update(companyMembers)
|
||||||
.set({ role })
|
.set({ roles })
|
||||||
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)));
|
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)));
|
||||||
|
|
||||||
if (member) {
|
if (member) {
|
||||||
await logCompanyEvent(params.companyId, admin.id, 'member_role_changed',
|
await logCompanyEvent(params.companyId, admin.id, 'member_role_changed',
|
||||||
`Changed ${member.displayName ?? member.email} role from ${member.oldRole} to ${role}`,
|
`Changed ${member.displayName ?? member.email} roles from [${member.oldRoles.join(', ')}] to [${roles.join(', ')}]`,
|
||||||
{ targetUserId: member.userId, oldRole: member.oldRole, newRole: role }
|
{ 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' });
|
if (!memberId) return fail(400, { error: 'Member ID required' });
|
||||||
|
|
||||||
// Get member info for the log
|
|
||||||
const [member] = await db
|
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)
|
.from(companyMembers)
|
||||||
.innerJoin(users, eq(companyMembers.userId, users.id))
|
.innerJoin(users, eq(companyMembers.userId, users.id))
|
||||||
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
|
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
|
||||||
@@ -139,8 +170,8 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
if (member) {
|
if (member) {
|
||||||
await logCompanyEvent(params.companyId, admin.id, 'member_removed',
|
await logCompanyEvent(params.companyId, admin.id, 'member_removed',
|
||||||
`Removed ${member.displayName ?? member.email} (was ${member.role})`,
|
`Removed ${member.displayName ?? member.email} (was ${member.roles.join(', ')})`,
|
||||||
{ targetUserId: member.userId, role: member.role }
|
{ targetUserId: member.userId, roles: member.roles }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const isAdmin = data.companyRole === 'admin';
|
const isAdmin = $derived(data.companyRoles.includes('admin'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -53,13 +53,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Members -->
|
||||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
|
<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>
|
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">Members</h2>
|
||||||
|
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<form method="POST" action="?/addMember" use:enhance class="mb-4 flex items-end gap-3">
|
<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 class="flex-1">
|
<div>
|
||||||
<label for="email" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Add Member by Email</label>
|
<label for="email" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Add Member by Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -67,20 +87,34 @@
|
|||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder="user@example.com"
|
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"
|
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>
|
||||||
<div class="w-32">
|
<div>
|
||||||
<label for="role" class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Role</label>
|
<span class="mb-1 block text-sm text-gray-700 dark:text-gray-300">Roles (one or more)</span>
|
||||||
<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">
|
<div class="flex flex-wrap gap-3">
|
||||||
<option value="viewer">Viewer</option>
|
{#each ['admin', 'manager', 'hr', 'user', 'viewer'] as role}
|
||||||
<option value="user">User</option>
|
<label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300">
|
||||||
<option value="manager">Manager</option>
|
<input type="checkbox" name="roles" value={role} class="rounded" checked={role === 'viewer'} />
|
||||||
<option value="admin">Admin</option>
|
{role}
|
||||||
</select>
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</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">
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -90,7 +124,7 @@
|
|||||||
<tr class="text-left text-gray-500 dark:text-gray-400">
|
<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">User</th>
|
||||||
<th class="px-4 py-3 font-medium">Email</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}
|
{#if isAdmin}
|
||||||
<th class="px-4 py-3 font-medium">Actions</th>
|
<th class="px-4 py-3 font-medium">Actions</th>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -99,24 +133,31 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each data.members as member}
|
{#each data.members as member}
|
||||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
<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 text-gray-500 dark:text-gray-400">{member.email}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<form method="POST" action="?/updateRole" use:enhance class="inline">
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateRoles"
|
||||||
|
use:enhance={() => async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
}}
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
<input type="hidden" name="memberId" value={member.id} />
|
<input type="hidden" name="memberId" value={member.id} />
|
||||||
<select
|
{#each ['admin', 'manager', 'hr', 'user', 'viewer'] as role}
|
||||||
name="role"
|
<label class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300">
|
||||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
<input type="checkbox" name="roles" value={role} checked={member.roles.includes(role as any)} class="rounded" />
|
||||||
class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white px-2 py-1 text-sm"
|
{role}
|
||||||
>
|
</label>
|
||||||
{#each ['viewer', 'user', 'manager', 'admin'] as role}
|
{/each}
|
||||||
<option value={role} selected={member.role === role}>{role}</option>
|
<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">
|
||||||
{/each}
|
Save
|
||||||
</select>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
{member.role}
|
<span class="dark:text-white">{member.roles.join(', ')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if isAdmin}
|
{#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,
|
companyMembers,
|
||||||
companies,
|
companies,
|
||||||
projects,
|
projects,
|
||||||
expenses
|
expenses,
|
||||||
|
leaveRequests,
|
||||||
|
payslips,
|
||||||
|
invoices
|
||||||
} from '$lib/server/db/schema.js';
|
} 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 }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const userId = locals.user!.id;
|
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
|
const userCompanies = await db
|
||||||
.select({
|
.select({
|
||||||
id: companies.id,
|
id: companies.id,
|
||||||
name: companies.name,
|
name: companies.name,
|
||||||
totalBudget: companies.totalBudget,
|
totalBudget: companies.totalBudget,
|
||||||
currency: companies.currency,
|
currency: companies.currency,
|
||||||
role: companyMembers.role
|
roles: companyMembers.roles
|
||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
.where(and(eq(companyMembers.userId, userId), isNull(companies.deletedAt)));
|
.where(and(eq(companyMembers.userId, userId), isNull(companies.deletedAt)));
|
||||||
|
|
||||||
// For each company, get project count and pending expense count
|
|
||||||
const companySummaries = await Promise.all(
|
const companySummaries = await Promise.all(
|
||||||
userCompanies.map(async (company) => {
|
userCompanies.map(async (company) => {
|
||||||
const [projectCount] = await db
|
const [projectCount] = await db
|
||||||
@@ -36,23 +38,48 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(expenses)
|
.from(expenses)
|
||||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||||
.where(
|
.where(and(eq(projects.companyId, company.id), eq(expenses.status, 'pending')));
|
||||||
and(eq(projects.companyId, company.id), eq(expenses.status, 'pending'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const [approvedTotal] = await db
|
const [approvedTotal] = await db
|
||||||
.select({ total: sql<string>`coalesce(sum(${expenses.amount}), 0)` })
|
.select({ total: sql<string>`coalesce(sum(${expenses.amount}), 0)` })
|
||||||
.from(expenses)
|
.from(expenses)
|
||||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
.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(
|
.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 {
|
return {
|
||||||
...company,
|
...company,
|
||||||
projectCount: projectCount.count,
|
projectCount: projectCount.count,
|
||||||
pendingExpenses: pendingCount.count,
|
pendingExpenses: pendingCount.count,
|
||||||
totalSpent: approvedTotal.total
|
totalSpent: approvedTotal.total,
|
||||||
|
pendingLeave: pendingLeave.count,
|
||||||
|
draftPayslips: draftPayslips.count,
|
||||||
|
overdueInvoices: overdueInvoices.count
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Dashboard - B4L Budget</title>
|
<title>Dashboard - {data.appName}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<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"
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,10 +64,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if company.pendingExpenses > 0}
|
{#if company.pendingExpenses > 0}
|
||||||
<div class="flex justify-between">
|
<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>
|
<span class="font-medium text-amber-600">{company.pendingExpenses}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Login - Buildfor Life Budget</title>
|
<title>Login - {data.appName}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
<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">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
let { form } = $props();
|
let { form, data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Sign Up - Buildfor Life Budget</title>
|
<title>Sign Up - {data.appName}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
<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 type { LayoutServerLoad } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
return {
|
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