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
+2 -2
View File
@@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.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 }) => {
if (!locals.user) {
@@ -18,7 +18,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
})
.from(companyMembers)
.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 {
user: locals.user,
+30 -3
View File
@@ -2,7 +2,7 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companies, companyMembers } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, and, isNull } from 'drizzle-orm';
import { logCompanyEvent } from '$lib/server/audit.js';
import { requireAuth } from '$lib/server/authorization.js';
@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ locals }) => {
})
.from(companyMembers)
.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 };
};
@@ -46,7 +46,6 @@ export const actions: Actions = {
.values({ name, description, currency, totalBudget })
.returning();
// Add creator as admin of the company
await db.insert(companyMembers).values({
userId: user.id,
companyId: company.id,
@@ -61,5 +60,33 @@ export const actions: Actions = {
}
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 };
}
};
+64 -14
View File
@@ -5,6 +5,8 @@
let { data, form } = $props();
let showCreateModal = $state(false);
let confirmDeleteId = $state<string | null>(null);
let confirmDeleteName = $state('');
</script>
<svelte:head>
@@ -27,6 +29,9 @@
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/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}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
@@ -41,21 +46,31 @@
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.companies as company}
<a
href="/companies/{company.id}"
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}
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
<div class="relative rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow">
<a href="/companies/{company.id}" class="block">
<h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
{#if company.description}
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
{/if}
<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}
<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>
</div>
{/each}
</div>
{/if}
@@ -136,3 +151,38 @@
</div>
</div>
{/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 { db } from '$lib/server/db/index.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';
export const load: LayoutServerLoad = async ({ locals, params }) => {
@@ -11,7 +11,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
const [company] = await db
.select()
.from(companies)
.where(eq(companies.id, params.companyId))
.where(and(eq(companies.id, params.companyId), isNull(companies.deletedAt)))
.limit(1);
if (!company) {
+2 -2
View File
@@ -6,7 +6,7 @@ import {
projects,
expenses
} 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 }) => {
const userId = locals.user!.id;
@@ -22,7 +22,7 @@ export const load: PageServerLoad = async ({ locals }) => {
})
.from(companyMembers)
.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
const companySummaries = await Promise.all(