Add soft-delete (archive) for companies, admin-only
- Added deletedAt column to companies table for soft delete - System admins see a trash icon on each company card with confirmation modal - Archived companies are filtered from sidebar, dashboard, company list, and direct access - Audit log entry created on archive Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ export const companies = pgTable('companies', {
|
|||||||
description: text('description'),
|
description: text('description'),
|
||||||
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||||
currency: text('currency').notNull().default('THB'),
|
currency: text('currency').notNull().default('THB'),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { companyMembers, companies } from '$lib/server/db/schema.js';
|
import { companyMembers, companies } from '$lib/server/db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
@@ -18,7 +18,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
.where(eq(companyMembers.userId, locals.user.id));
|
.where(and(eq(companyMembers.userId, locals.user.id), isNull(companies.deletedAt)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 } from '$lib/server/db/schema.js';
|
||||||
import { eq } 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';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
.where(eq(companyMembers.userId, user.id));
|
.where(and(eq(companyMembers.userId, user.id), isNull(companies.deletedAt)));
|
||||||
|
|
||||||
return { companies: userCompanies, isSystemAdmin: user.isSystemAdmin };
|
return { companies: userCompanies, isSystemAdmin: user.isSystemAdmin };
|
||||||
};
|
};
|
||||||
@@ -46,7 +46,6 @@ export const actions: Actions = {
|
|||||||
.values({ name, description, currency, totalBudget })
|
.values({ name, description, currency, totalBudget })
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Add creator as admin of the company
|
|
||||||
await db.insert(companyMembers).values({
|
await db.insert(companyMembers).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
@@ -61,5 +60,33 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
redirect(302, `/companies/${company.id}`);
|
redirect(302, `/companies/${company.id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
if (!user.isSystemAdmin) {
|
||||||
|
return fail(403, { error: 'Only system admins can delete companies' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const companyId = formData.get('companyId')?.toString();
|
||||||
|
if (!companyId) return fail(400, { error: 'Company ID required' });
|
||||||
|
|
||||||
|
const [company] = await db
|
||||||
|
.select({ name: companies.name })
|
||||||
|
.from(companies)
|
||||||
|
.where(and(eq(companies.id, companyId), isNull(companies.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!company) return fail(404, { error: 'Company not found' });
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(companies)
|
||||||
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(companies.id, companyId));
|
||||||
|
|
||||||
|
await logCompanyEvent(companyId, user.id, 'company_updated', `Company "${company.name}" archived (soft-deleted)`);
|
||||||
|
|
||||||
|
return { success: true, deleted: company.name };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
|
let confirmDeleteId = $state<string | null>(null);
|
||||||
|
let confirmDeleteName = $state('');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -27,6 +29,9 @@
|
|||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
|
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if form?.deleted}
|
||||||
|
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700">Company "{form.deleted}" has been archived.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.companies.length === 0}
|
{#if data.companies.length === 0}
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
||||||
@@ -41,21 +46,31 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each data.companies as company}
|
{#each data.companies as company}
|
||||||
<a
|
<div class="relative rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow">
|
||||||
href="/companies/{company.id}"
|
<a href="/companies/{company.id}" class="block">
|
||||||
class="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
<h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
|
||||||
>
|
{#if company.description}
|
||||||
<h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
|
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
|
||||||
{#if company.description}
|
{/if}
|
||||||
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
|
<div class="mt-3 flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-500">Budget: {formatCurrency(company.totalBudget, company.currency)}</span>
|
||||||
|
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{company.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{#if data.isSystemAdmin}
|
||||||
|
<button
|
||||||
|
onclick={(e) => { e.stopPropagation(); confirmDeleteId = company.id; confirmDeleteName = company.name; }}
|
||||||
|
class="absolute top-3 right-3 rounded p-1 text-gray-300 hover:bg-red-50 hover:text-red-500"
|
||||||
|
title="Archive company"
|
||||||
|
>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="mt-3 flex items-center justify-between text-sm">
|
</div>
|
||||||
<span class="text-gray-500">Budget: {formatCurrency(company.totalBudget, company.currency)}</span>
|
|
||||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
|
||||||
{company.role}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -136,3 +151,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
{#if confirmDeleteId}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Archive Company</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Are you sure you want to archive <strong>{confirmDeleteName}</strong>? The company and all its data will be hidden but not permanently deleted.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDeleteId = null)}
|
||||||
|
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="?/delete" use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
confirmDeleteId = null;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="companyId" value={confirmDeleteId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
|
|||||||
import type { LayoutServerLoad } from './$types';
|
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 } from 'drizzle-orm';
|
import { eq, and, isNull } from 'drizzle-orm';
|
||||||
import { requireAuth, getCompanyRole } from '$lib/server/authorization.js';
|
import { requireAuth, getCompanyRole } from '$lib/server/authorization.js';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals, params }) => {
|
export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||||
@@ -11,7 +11,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
|||||||
const [company] = await db
|
const [company] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(companies)
|
.from(companies)
|
||||||
.where(eq(companies.id, params.companyId))
|
.where(and(eq(companies.id, params.companyId), isNull(companies.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!company) {
|
if (!company) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
projects,
|
projects,
|
||||||
expenses
|
expenses
|
||||||
} from '$lib/server/db/schema.js';
|
} from '$lib/server/db/schema.js';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql, isNull } 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;
|
||||||
@@ -22,7 +22,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
})
|
})
|
||||||
.from(companyMembers)
|
.from(companyMembers)
|
||||||
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
|
||||||
.where(eq(companyMembers.userId, userId));
|
.where(and(eq(companyMembers.userId, userId), isNull(companies.deletedAt)));
|
||||||
|
|
||||||
// For each company, get project count and pending expense count
|
// For each company, get project count and pending expense count
|
||||||
const companySummaries = await Promise.all(
|
const companySummaries = await Promise.all(
|
||||||
|
|||||||
Reference in New Issue
Block a user