Initial commit: Buildfor Life Budget app
Multi-company budget/project tracking tool built with SvelteKit 5, PostgreSQL (Drizzle ORM), and Tailwind CSS v4. Features: - Auth: local (email/password with Argon2) + generic OIDC - 4 roles per company: admin, manager, user, viewer - Multi-company with per-company user membership - Projects with budget allocation from company pool - Expense submission with approval workflow - Categories and tags for expense organization - Reports with spending breakdowns (by category, project, time) - CSV import for Actual Budget migration - Company audit log tracking all budget and admin actions - Remaining budget hero display on overview and budget pages - Admin-only company creation; new users wait for invitation - Deployment configs for systemd + nginx (bare metal/Proxmox) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { CompanyRole } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
user: { id: string; email: string; displayName: string | null; isSystemAdmin: boolean };
|
||||
companies: Array<{ companyId: string; companyName: string; role: CompanyRole }>;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
let { user, companies, open, onToggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="flex w-64 flex-col border-r border-gray-200 bg-white transition-transform duration-200 {open
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full'} fixed inset-y-0 left-0 z-30 lg:relative lg:translate-x-0"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-14 items-center border-b border-gray-200 px-4">
|
||||
<a href="/dashboard" class="text-lg font-bold text-gray-900">B4L Budget</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
{#if companies.length > 0}
|
||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||
Companies
|
||||
</div>
|
||||
{#each companies as company}
|
||||
<a
|
||||
href="/companies/{company.companyId}"
|
||||
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-blue-100 text-xs font-medium text-blue-700"
|
||||
>
|
||||
{company.companyName[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span class="truncate">{company.companyName}</span>
|
||||
<span class="ml-auto text-xs text-gray-400">{company.role}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if user.isSystemAdmin}
|
||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||
Administration
|
||||
</div>
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Manage Users
|
||||
</a>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Backdrop for mobile -->
|
||||
{#if open}
|
||||
<button
|
||||
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
|
||||
onclick={onToggle}
|
||||
aria-label="Close sidebar"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { db } from './db/index.js';
|
||||
import { companyLog } from './db/schema.js';
|
||||
|
||||
type LogEvent = typeof companyLog.$inferInsert['event'];
|
||||
|
||||
export async function logCompanyEvent(
|
||||
companyId: string,
|
||||
userId: string | null,
|
||||
event: LogEvent,
|
||||
description: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await db.insert(companyLog).values({
|
||||
companyId,
|
||||
userId,
|
||||
event,
|
||||
description,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
encodeBase32LowerCaseNoPadding,
|
||||
encodeHexLowerCase
|
||||
} from '@oslojs/encoding';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { db } from '../db/index.js';
|
||||
import { sessions, users } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const SESSION_REFRESH_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
const bytes = new Uint8Array(20);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase32LowerCaseNoPadding(bytes);
|
||||
}
|
||||
|
||||
export function generateUserId(): string {
|
||||
const bytes = new Uint8Array(15);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase32LowerCaseNoPadding(bytes);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
const encoded = new TextEncoder().encode(token);
|
||||
return encodeHexLowerCase(sha256(encoded));
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
token: string,
|
||||
userId: string
|
||||
): Promise<{ id: string; userId: string; expiresAt: Date; fresh: boolean }> {
|
||||
const sessionId = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt
|
||||
});
|
||||
|
||||
return { id: sessionId, userId, expiresAt, fresh: true };
|
||||
}
|
||||
|
||||
export async function validateSession(
|
||||
token: string
|
||||
): Promise<{
|
||||
session: { id: string; expiresAt: Date; fresh: boolean } | null;
|
||||
user: App.Locals['user'];
|
||||
}> {
|
||||
const sessionId = hashToken(token);
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
session: sessions,
|
||||
user: {
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
isSystemAdmin: users.isSystemAdmin
|
||||
}
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const { session, user } = result[0];
|
||||
|
||||
// Session expired
|
||||
if (Date.now() >= session.expiresAt.getTime()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
// Extend session if within refresh window
|
||||
let fresh = false;
|
||||
if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_MS) {
|
||||
const newExpiry = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ expiresAt: newExpiry })
|
||||
.where(eq(sessions.id, sessionId));
|
||||
session.expiresAt = newExpiry;
|
||||
fresh = true;
|
||||
}
|
||||
|
||||
return {
|
||||
session: { id: session.id, expiresAt: session.expiresAt, fresh },
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
export async function invalidateSession(token: string): Promise<void> {
|
||||
const sessionId = hashToken(token);
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionCookie(event: RequestEvent, token: string, expiresAt: Date): void {
|
||||
event.cookies.set('session', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
path: '/',
|
||||
secure: event.url.protocol === 'https:'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionCookie(event: RequestEvent): void {
|
||||
event.cookies.set('session', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 0,
|
||||
path: '/',
|
||||
secure: event.url.protocol === 'https:'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
interface OIDCConfig {
|
||||
issuerUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
}
|
||||
|
||||
let oidcConfig: OIDCConfig | null = null;
|
||||
|
||||
export function isOIDCEnabled(): boolean {
|
||||
return !!(env.OIDC_ISSUER_URL && env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
export async function getOIDCConfig(): Promise<OIDCConfig> {
|
||||
if (oidcConfig) return oidcConfig;
|
||||
|
||||
if (!isOIDCEnabled()) {
|
||||
throw new Error('OIDC is not configured');
|
||||
}
|
||||
|
||||
const issuerUrl = env.OIDC_ISSUER_URL!.replace(/\/$/, '');
|
||||
const discoveryUrl = `${issuerUrl}/.well-known/openid-configuration`;
|
||||
const res = await fetch(discoveryUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch OIDC discovery: ${res.status}`);
|
||||
}
|
||||
|
||||
const discovery = await res.json();
|
||||
|
||||
oidcConfig = {
|
||||
issuerUrl,
|
||||
clientId: env.OIDC_CLIENT_ID!,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET!,
|
||||
redirectUri: env.OIDC_REDIRECT_URI || `${env.ORIGIN}/oidc/callback`,
|
||||
authorizationEndpoint: discovery.authorization_endpoint,
|
||||
tokenEndpoint: discovery.token_endpoint,
|
||||
userinfoEndpoint: discovery.userinfo_endpoint
|
||||
};
|
||||
|
||||
return oidcConfig;
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoded = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export async function getAuthorizationUrl(state: string, codeVerifier: string): Promise<string> {
|
||||
const config = await getOIDCConfig();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: 'openid email profile',
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
return `${config.authorizationEndpoint}?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<{ accessToken: string; idToken?: string }> {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
const res = await fetch(config.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { accessToken: data.access_token, idToken: data.id_token };
|
||||
}
|
||||
|
||||
export async function getUserInfo(
|
||||
accessToken: string
|
||||
): Promise<{ sub: string; email: string; name?: string }> {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
const res = await fetch(config.userinfoEndpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`UserInfo request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
const ARGON2_OPTIONS = {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1
|
||||
};
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
|
||||
return verify(passwordHash, password, ARGON2_OPTIONS);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from './db/index.js';
|
||||
import { companyMembers } from './db/schema.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { ROLE_HIERARCHY, type CompanyRole } from '$lib/types/index.js';
|
||||
|
||||
export function requireAuth(locals: App.Locals): NonNullable<App.Locals['user']> {
|
||||
if (!locals.user) {
|
||||
error(401, 'Authentication required');
|
||||
}
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['user']> {
|
||||
const user = requireAuth(locals);
|
||||
if (!user.isSystemAdmin) {
|
||||
error(403, 'System admin access required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getCompanyRole(
|
||||
userId: string,
|
||||
companyId: string
|
||||
): Promise<CompanyRole | null> {
|
||||
const result = await db
|
||||
.select({ role: companyMembers.role })
|
||||
.from(companyMembers)
|
||||
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) return null;
|
||||
return result[0].role;
|
||||
}
|
||||
|
||||
export async function requireCompanyRole(
|
||||
locals: App.Locals,
|
||||
companyId: string,
|
||||
minRole: CompanyRole
|
||||
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
// System admins bypass company role checks
|
||||
if (user.isSystemAdmin) {
|
||||
return { user, role: 'admin' };
|
||||
}
|
||||
|
||||
const role = await getCompanyRole(user.id, companyId);
|
||||
|
||||
if (!role) {
|
||||
error(403, 'Not a member of this company');
|
||||
}
|
||||
|
||||
if (ROLE_HIERARCHY[role] < ROLE_HIERARCHY[minRole]) {
|
||||
error(403, `Requires ${minRole} role or higher`);
|
||||
}
|
||||
|
||||
return { user, role };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import * as schema from './schema.js';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: env.DATABASE_URL
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
export type Database = typeof db;
|
||||
@@ -0,0 +1,309 @@
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
numeric,
|
||||
date,
|
||||
index,
|
||||
primaryKey
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────
|
||||
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||
|
||||
// ── Users ──────────────────────────────────────────────
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
username: text('username').unique(),
|
||||
displayName: text('display_name'),
|
||||
passwordHash: text('password_hash'),
|
||||
oidcProvider: text('oidc_provider'),
|
||||
oidcSubject: text('oidc_subject'),
|
||||
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('users_oidc_idx')
|
||||
.on(table.oidcProvider, table.oidcSubject)
|
||||
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
|
||||
});
|
||||
|
||||
// ── Companies ──────────────────────────────────────────
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Company Members ────────────────────────────────────
|
||||
|
||||
export const companyMembers = pgTable(
|
||||
'company_members',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
role: companyRoleEnum('role').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
|
||||
);
|
||||
|
||||
// ── Projects ───────────────────────────────────────────
|
||||
|
||||
export const projects = pgTable('projects', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Categories ─────────────────────────────────────────
|
||||
|
||||
export const categories = pgTable(
|
||||
'categories',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
color: text('color'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
|
||||
);
|
||||
|
||||
// ── Expenses ───────────────────────────────────────────
|
||||
|
||||
export const expenses = pgTable(
|
||||
'expenses',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
approvedBy: text('approved_by').references(() => users.id),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
receiptUrl: text('receipt_url'),
|
||||
expenseDate: date('expense_date').notNull(),
|
||||
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||
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()
|
||||
},
|
||||
(table) => [
|
||||
index('expenses_project_status_idx').on(table.projectId, table.status),
|
||||
index('expenses_submitted_by_idx').on(table.submittedBy),
|
||||
index('expenses_date_idx').on(table.expenseDate)
|
||||
]
|
||||
);
|
||||
|
||||
// ── Tags ───────────────────────────────────────────────
|
||||
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
|
||||
);
|
||||
|
||||
export const expenseTags = pgTable(
|
||||
'expense_tags',
|
||||
{
|
||||
expenseId: uuid('expense_id')
|
||||
.notNull()
|
||||
.references(() => expenses.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
|
||||
);
|
||||
|
||||
// ── Budget Allocations ─────────────────────────────────
|
||||
|
||||
export const budgetAllocations = pgTable('budget_allocations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
allocatedBy: text('allocated_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
note: text('note'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
|
||||
// ── Company Log (Audit Trail) ──────────────────────────
|
||||
|
||||
export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'company_created',
|
||||
'company_updated',
|
||||
'budget_initial',
|
||||
'budget_added',
|
||||
'budget_allocated',
|
||||
'budget_deallocated',
|
||||
'project_created',
|
||||
'project_updated',
|
||||
'member_added',
|
||||
'member_removed',
|
||||
'member_role_changed',
|
||||
'expense_submitted',
|
||||
'expense_approved',
|
||||
'expense_rejected',
|
||||
'category_created',
|
||||
'import_completed'
|
||||
]);
|
||||
|
||||
export const companyLog = pgTable(
|
||||
'company_log',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
event: companyLogEventEnum('event').notNull(),
|
||||
description: text('description').notNull(),
|
||||
metadata: text('metadata'), // JSON string for extra context (amounts, names, etc.)
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('company_log_company_idx').on(table.companyId, table.createdAt)]
|
||||
);
|
||||
|
||||
// ── Relations ──────────────────────────────────────────
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
sessions: many(sessions),
|
||||
companyMemberships: many(companyMembers),
|
||||
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
|
||||
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
|
||||
}));
|
||||
|
||||
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, { fields: [sessions.userId], references: [users.id] })
|
||||
}));
|
||||
|
||||
export const companiesRelations = relations(companies, ({ many }) => ({
|
||||
members: many(companyMembers),
|
||||
projects: many(projects),
|
||||
categories: many(categories),
|
||||
tags: many(tags),
|
||||
budgetAllocations: many(budgetAllocations),
|
||||
logs: many(companyLog)
|
||||
}));
|
||||
|
||||
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
|
||||
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
|
||||
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
|
||||
}));
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
|
||||
expenses: many(expenses),
|
||||
budgetAllocations: many(budgetAllocations)
|
||||
}));
|
||||
|
||||
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
|
||||
expenses: many(expenses)
|
||||
}));
|
||||
|
||||
export const expensesRelations = relations(expenses, ({ one, many }) => ({
|
||||
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
|
||||
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
|
||||
submitter: one(users, {
|
||||
fields: [expenses.submittedBy],
|
||||
references: [users.id],
|
||||
relationName: 'submittedExpenses'
|
||||
}),
|
||||
approver: one(users, {
|
||||
fields: [expenses.approvedBy],
|
||||
references: [users.id],
|
||||
relationName: 'approvedExpenses'
|
||||
}),
|
||||
expenseTags: many(expenseTags)
|
||||
}));
|
||||
|
||||
export const tagsRelations = relations(tags, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
|
||||
expenseTags: many(expenseTags)
|
||||
}));
|
||||
|
||||
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
|
||||
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
|
||||
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
|
||||
}));
|
||||
|
||||
export const companyLogRelations = relations(companyLog, ({ one }) => ({
|
||||
company: one(companies, { fields: [companyLog.companyId], references: [companies.id] }),
|
||||
user: one(users, { fields: [companyLog.userId], references: [users.id] })
|
||||
}));
|
||||
|
||||
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
|
||||
company: one(companies, {
|
||||
fields: [budgetAllocations.companyId],
|
||||
references: [companies.id]
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [budgetAllocations.projectId],
|
||||
references: [projects.id]
|
||||
}),
|
||||
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
|
||||
}));
|
||||
@@ -0,0 +1,20 @@
|
||||
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { companies } from './companies.js';
|
||||
import { projects } from './projects.js';
|
||||
import { users } from './users.js';
|
||||
|
||||
export const budgetAllocations = pgTable('budget_allocations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
allocatedBy: text('allocated_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
note: text('note'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { companies } from './companies.js';
|
||||
|
||||
export const categories = pgTable(
|
||||
'categories',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
color: text('color'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { pgTable, uuid, text, timestamp, uniqueIndex, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
import { companies } from './companies.js';
|
||||
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
|
||||
|
||||
export const companyMembers = pgTable(
|
||||
'company_members',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
role: companyRoleEnum('role').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
numeric,
|
||||
date,
|
||||
timestamp,
|
||||
index,
|
||||
pgEnum
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { projects } from './projects.js';
|
||||
import { categories } from './categories.js';
|
||||
import { users } from './users.js';
|
||||
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||
|
||||
export const expenses = pgTable(
|
||||
'expenses',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
approvedBy: text('approved_by').references(() => users.id),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
receiptUrl: text('receipt_url'),
|
||||
expenseDate: date('expense_date').notNull(),
|
||||
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||
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()
|
||||
},
|
||||
(table) => [
|
||||
index('expenses_project_status_idx').on(table.projectId, table.status),
|
||||
index('expenses_submitted_by_idx').on(table.submittedBy),
|
||||
index('expenses_date_idx').on(table.expenseDate)
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
export { users } from './users.js';
|
||||
export { sessions } from './sessions.js';
|
||||
export { companies } from './companies.js';
|
||||
export { companyRoleEnum, companyMembers } from './company-members.js';
|
||||
export { projects } from './projects.js';
|
||||
export { categories } from './categories.js';
|
||||
export { expenseStatusEnum, expenses } from './expenses.js';
|
||||
export { tags, expenseTags } from './tags.js';
|
||||
export { budgetAllocations } from './budget-allocations.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { pgTable, uuid, text, numeric, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { companies } from './companies.js';
|
||||
|
||||
export const projects = pgTable('projects', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { users } from './users.js';
|
||||
import { sessions } from './sessions.js';
|
||||
import { companies } from './companies.js';
|
||||
import { companyMembers } from './company-members.js';
|
||||
import { projects } from './projects.js';
|
||||
import { categories } from './categories.js';
|
||||
import { expenses } from './expenses.js';
|
||||
import { tags, expenseTags } from './tags.js';
|
||||
import { budgetAllocations } from './budget-allocations.js';
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
sessions: many(sessions),
|
||||
companyMemberships: many(companyMembers),
|
||||
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
|
||||
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
|
||||
}));
|
||||
|
||||
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, { fields: [sessions.userId], references: [users.id] })
|
||||
}));
|
||||
|
||||
export const companiesRelations = relations(companies, ({ many }) => ({
|
||||
members: many(companyMembers),
|
||||
projects: many(projects),
|
||||
categories: many(categories),
|
||||
tags: many(tags),
|
||||
budgetAllocations: many(budgetAllocations)
|
||||
}));
|
||||
|
||||
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
|
||||
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
|
||||
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
|
||||
}));
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
|
||||
expenses: many(expenses),
|
||||
budgetAllocations: many(budgetAllocations)
|
||||
}));
|
||||
|
||||
export const categoriesRelations = relations(categories, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
|
||||
expenses: many(expenses)
|
||||
}));
|
||||
|
||||
export const expensesRelations = relations(expenses, ({ one, many }) => ({
|
||||
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
|
||||
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
|
||||
submitter: one(users, {
|
||||
fields: [expenses.submittedBy],
|
||||
references: [users.id],
|
||||
relationName: 'submittedExpenses'
|
||||
}),
|
||||
approver: one(users, {
|
||||
fields: [expenses.approvedBy],
|
||||
references: [users.id],
|
||||
relationName: 'approvedExpenses'
|
||||
}),
|
||||
expenseTags: many(expenseTags)
|
||||
}));
|
||||
|
||||
export const tagsRelations = relations(tags, ({ one, many }) => ({
|
||||
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
|
||||
expenseTags: many(expenseTags)
|
||||
}));
|
||||
|
||||
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
|
||||
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
|
||||
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
|
||||
}));
|
||||
|
||||
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
|
||||
company: one(companies, {
|
||||
fields: [budgetAllocations.companyId],
|
||||
references: [companies.id]
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [budgetAllocations.projectId],
|
||||
references: [projects.id]
|
||||
}),
|
||||
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
|
||||
}));
|
||||
@@ -0,0 +1,10 @@
|
||||
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { pgTable, uuid, text, uniqueIndex, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { companies } from './companies.js';
|
||||
import { expenses } from './expenses.js';
|
||||
|
||||
export const tags = pgTable(
|
||||
'tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
|
||||
);
|
||||
|
||||
export const expenseTags = pgTable(
|
||||
'expense_tags',
|
||||
{
|
||||
expenseId: uuid('expense_id')
|
||||
.notNull()
|
||||
.references(() => expenses.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { pgTable, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
username: text('username').unique(),
|
||||
displayName: text('display_name'),
|
||||
passwordHash: text('password_hash'),
|
||||
oidcProvider: text('oidc_provider'),
|
||||
oidcSubject: text('oidc_subject'),
|
||||
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('users_oidc_idx')
|
||||
.on(table.oidcProvider, table.oidcSubject)
|
||||
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
|
||||
export type ExpenseStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export const ROLE_HIERARCHY: Record<CompanyRole, number> = {
|
||||
admin: 4,
|
||||
manager: 3,
|
||||
user: 2,
|
||||
viewer: 1
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export function budgetPercent(spent: string | number, budget: string | number): number {
|
||||
const s = typeof spent === 'string' ? parseFloat(spent) : spent;
|
||||
const b = typeof budget === 'string' ? parseFloat(budget) : budget;
|
||||
if (b <= 0) return 0;
|
||||
return Math.min((s / b) * 100, 100);
|
||||
}
|
||||
|
||||
export function budgetColor(pct: number): string {
|
||||
if (pct > 90) return 'bg-red-500';
|
||||
if (pct > 70) return 'bg-amber-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const CURRENCY_FORMATS: Record<string, { locale: string; minimumFractionDigits: number }> = {
|
||||
THB: { locale: 'th-TH', minimumFractionDigits: 2 },
|
||||
USD: { locale: 'en-US', minimumFractionDigits: 2 },
|
||||
EUR: { locale: 'de-DE', minimumFractionDigits: 2 }
|
||||
};
|
||||
|
||||
export function formatCurrency(amount: string | number, currency: string = 'THB'): string {
|
||||
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
if (isNaN(num)) return `0.00 ${currency}`;
|
||||
|
||||
const fmt = CURRENCY_FORMATS[currency] ?? { locale: 'en-US', minimumFractionDigits: 2 };
|
||||
|
||||
return new Intl.NumberFormat(fmt.locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: fmt.minimumFractionDigits
|
||||
}).format(num);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return format(d, 'MMM d, yyyy HH:mm');
|
||||
}
|
||||
|
||||
export function timeAgo(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return formatDistanceToNow(d, { addSuffix: true });
|
||||
}
|
||||
Reference in New Issue
Block a user