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'),
|
||||
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_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 { 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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,10 +46,8 @@
|
||||
{: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"
|
||||
>
|
||||
<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>
|
||||
@@ -56,6 +59,18 @@
|
||||
</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>
|
||||
{/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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user