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:
2026-04-06 13:09:27 +07:00
parent 7a4ba0537f
commit d58443ed73
6 changed files with 101 additions and 23 deletions
+1
View File
@@ -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 -2
View File
@@ -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,
+30 -3
View File
@@ -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 };
} }
}; };
+54 -4
View File
@@ -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,10 +46,8 @@
{: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> <h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
{#if company.description} {#if company.description}
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p> <p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
@@ -56,6 +59,18 @@
</span> </span>
</div> </div>
</a> </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}
</div>
{/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) {
+2 -2
View File
@@ -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(