feat(admin): Task 33 — applications list, detail, status actions, homevisit notes, contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:30:51 -05:00
co-authored by Claude Sonnet 4.6
parent c1efb85c14
commit f06e85e6f8
6 changed files with 843 additions and 0 deletions
@@ -0,0 +1,51 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { applications, pets } from '$lib/server/schema';
import { eq, and, like, count, desc } from 'drizzle-orm';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { error } from '@sveltejs/kit';
const PER_PAGE = 20;
export const load: PageServerLoad = async ({ url, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const q = url.searchParams.get('q')?.trim() ?? '';
const type = url.searchParams.get('type') ?? '';
const status = url.searchParams.get('status') ?? '';
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
const offset = (page - 1) * PER_PAGE;
const conditions = [];
if (q) conditions.push(like(applications.applicantName, `%${q}%`));
if (type) conditions.push(eq(applications.type, type as typeof applications.type.enumValues[number]));
if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number]));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalRow] = await db.select({ total: count() }).from(applications).where(where);
const total = totalRow?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
const rows = await db
.select({
id: applications.id,
type: applications.type,
petId: applications.petId,
applicantName: applications.applicantName,
email: applications.applicantEmail,
phone: applications.applicantPhone,
status: applications.status,
createdAt: applications.createdAt,
petName: pets.name
})
.from(applications)
.leftJoin(pets, eq(applications.petId, pets.id))
.where(where)
.orderBy(desc(applications.createdAt))
.limit(PER_PAGE)
.offset(offset);
return { applications: rows, total, page, totalPages, q, type, status };
};
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
import Pagination from '$lib/components/ui/Pagination.svelte';
import { page as pageState } from '$app/state';
let { data } = $props();
const apps = $derived(data.applications);
const typeOptions = [
{ value: '', label: 'All Types' },
{ value: 'adoption', label: 'Adoption' },
{ value: 'foster', label: 'Foster' },
{ value: 'volunteer', label: 'Volunteer' },
{ value: 'surrender', label: 'Surrender' }
];
const statusOptions = [
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'reviewing', label: 'Reviewing' },
{ value: 'approved', label: 'Approved' },
{ value: 'denied', label: 'Denied' },
{ value: 'withdrawn', label: 'Withdrawn' }
];
const typeColorMap: Record<string, string> = {
adoption: 'bg-blue-100 text-blue-800',
foster: 'bg-purple-100 text-purple-800',
volunteer: 'bg-green-100 text-green-800',
surrender: 'bg-orange-100 text-orange-800'
};
function formatDate(dateStr: string | Date): string {
const d = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function formatType(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const selectClass = inputClass;
const currentUrl = $derived(pageState.url.pathname + pageState.url.search);
</script>
<svelte:head>
<title>Applications — Admin</title>
</svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Applications</h1>
<span class="text-sm text-gray-500">{data.total} total</span>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-4 mb-6">
<form method="GET" class="flex flex-wrap gap-3">
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="q"
value={data.q}
placeholder="Search by applicant name..."
class={inputClass}
/>
</div>
<div class="w-40">
<select name="type" class={selectClass}>
{#each typeOptions as opt}
<option value={opt.value} selected={data.type === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div class="w-40">
<select name="status" class={selectClass}>
{#each statusOptions as opt}
<option value={opt.value} selected={data.status === opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<button
type="submit"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors whitespace-nowrap cursor-pointer"
>
Filter
</button>
{#if data.q || data.type || data.status}
<a
href="/admin/applications"
class="px-5 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors whitespace-nowrap"
>
Clear
</a>
{/if}
</form>
</div>
{#if apps.length === 0}
<div class="text-center py-12 text-gray-400">
<svg class="mx-auto h-12 w-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-sm font-medium">{data.q || data.type || data.status ? 'No applications match your filters' : 'No applications yet'}</p>
</div>
{:else}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead>
<tr class="border-b border-gray-soft bg-gray-50">
<th class="px-6 py-3 font-semibold text-gray-600">Applicant</th>
<th class="px-6 py-3 font-semibold text-gray-600">Email</th>
<th class="px-6 py-3 font-semibold text-gray-600">Type</th>
<th class="px-6 py-3 font-semibold text-gray-600">Status</th>
<th class="px-6 py-3 font-semibold text-gray-600">Pet</th>
<th class="px-6 py-3 font-semibold text-gray-600">Date</th>
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each apps as app}
<tr class="border-b border-gray-soft last:border-0 hover:bg-gray-50 transition-colors">
<td class="px-6 py-4">
<a href="/admin/applications/{app.id}" class="font-medium text-teal-brand hover:text-teal-dark">
{app.applicantName}
</a>
</td>
<td class="px-6 py-4 text-gray-600">
<a href="mailto:{app.email}" class="hover:text-teal-brand">{app.email}</a>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {typeColorMap[app.type] ?? 'bg-gray-100 text-gray-800'}">
{formatType(app.type)}
</span>
</td>
<td class="px-6 py-4">
<StatusBadge status={app.status} />
</td>
<td class="px-6 py-4 text-gray-600">
{#if app.petName}
<a href="/admin/pets/{app.petId}" class="hover:text-teal-brand">{app.petName}</a>
{:else}
<span class="text-gray-400">—</span>
{/if}
</td>
<td class="px-6 py-4 text-gray-500 text-xs">
{formatDate(app.createdAt)}
</td>
<td class="px-6 py-4 text-right">
<a href="/admin/applications/{app.id}" class="text-sm font-medium text-teal-brand hover:text-teal-dark">
View
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<Pagination page={data.page} totalPages={data.totalPages} baseUrl={currentUrl} />
{/if}
</div>
@@ -0,0 +1,179 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { applications, pets, users, applicationHomevisitNotes } from '$lib/server/schema';
import { eq, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { logAudit } from '$lib/server/audit';
import { sendApplicationStatusUpdate, sendFosterWelcomeEmail } from '$lib/server/email';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
import { hashPassword } from '$lib/server/auth-utils';
import crypto from 'node:crypto';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
if (isNaN(id)) error(404, 'Application not found');
const [app] = await db.select().from(applications).where(eq(applications.id, id));
if (!app) error(404, 'Application not found');
let pet = null;
if (app.petId) {
const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId));
pet = petRecord ?? null;
}
const homevisitNotes = await db
.select({
id: applicationHomevisitNotes.id,
notes: applicationHomevisitNotes.notes,
visitDate: applicationHomevisitNotes.visitDate,
createdAt: applicationHomevisitNotes.createdAt,
userName: users.name
})
.from(applicationHomevisitNotes)
.innerJoin(users, eq(applicationHomevisitNotes.userId, users.id))
.where(eq(applicationHomevisitNotes.applicationId, id))
.orderBy(asc(applicationHomevisitNotes.createdAt));
return { application: app, pet, homevisitNotes };
};
export const actions: Actions = {
updateStatus: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const status = fd.get('status')?.toString();
if (!status || !['pending', 'reviewing', 'approved', 'denied', 'withdrawn'].includes(status)) {
return fail(400, { error: 'Invalid status' });
}
await db
.update(applications)
.set({ status: status as typeof applications.status.enumValues[number] })
.where(eq(applications.id, id));
await logAudit({
userId: locals.user?.id ?? undefined,
action: 'update_application_status',
entity: 'application',
entityId: id,
diff: { status }
});
// Send status update email
try {
const [app] = await db
.select({ applicantName: applications.applicantName, email: applications.applicantEmail, type: applications.type })
.from(applications)
.where(eq(applications.id, id));
if (app) {
await sendApplicationStatusUpdate(
process.env.ORG_NAME ?? 'Rescue',
app.email,
app.applicantName,
app.type,
status,
process.env.SITE_URL ?? ''
);
// Auto-create foster account on approval
if (status === 'approved' && app.type === 'foster') {
const [existingUser] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, app.email))
.limit(1);
if (!existingUser) {
const tempPassword = crypto.randomBytes(4).toString('hex');
const passwordHash = await hashPassword(tempPassword);
await db.insert(users).values({
email: app.email,
name: app.applicantName,
role: 'foster',
passwordHash,
mustChangePassword: true,
active: true
});
await sendFosterWelcomeEmail(
process.env.ORG_NAME ?? 'Rescue',
app.email,
app.applicantName,
tempPassword,
`${process.env.SITE_URL ?? ''}/login`
);
}
}
}
} catch {
// Email failure should not break status update
}
return { success: true, action: 'updateStatus' };
},
addNote: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const note = fd.get('note')?.toString().trim();
if (!note) return fail(400, { error: 'Note cannot be empty' });
const [app] = await db
.select({ reviewNotes: applications.reviewNotes })
.from(applications)
.where(eq(applications.id, id));
const timestamp = new Date().toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const userName = locals.user?.name ?? 'Unknown';
const newNote = `[${timestamp} — ${userName}] ${note}`;
const updatedNotes = app?.reviewNotes ? `${app.reviewNotes}\n\n${newNote}` : newNote;
await db.update(applications).set({ reviewNotes: updatedNotes }).where(eq(applications.id, id));
await logAudit({
userId: locals.user?.id ?? undefined,
action: 'add_application_note',
entity: 'application',
entityId: id
});
return { success: true, action: 'addNote' };
},
addHomevisitNote: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const notes = fd.get('notes')?.toString().trim();
const visitDate = fd.get('visitDate')?.toString() || null;
if (!notes) return fail(400, { error: 'Note cannot be empty' });
await db.insert(applicationHomevisitNotes).values({
applicationId: id,
userId: locals.user!.id,
notes,
visitDate: visitDate ? new Date(visitDate) : null
});
await logAudit({
userId: locals.user?.id ?? undefined,
action: 'add_homevisit_note',
entity: 'application',
entityId: id
});
return { success: true, action: 'addHomevisitNote' };
}
};
@@ -0,0 +1,354 @@
<script lang="ts">
import { enhance } from '$app/forms';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
let { data, form } = $props();
const app = $derived(data.application);
const pet = $derived(data.pet);
const homevisitNotes = $derived(data.homevisitNotes);
let showStatusSuccess = $state(false);
let showNoteSuccess = $state(false);
let showHomevisitNoteSuccess = $state(false);
let submittingStatus = $state(false);
let submittingNote = $state(false);
let submittingHomevisitNote = $state(false);
function handleStatusEnhance() {
submittingStatus = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submittingStatus = false;
await update({ reset: false });
if (result.type === 'success') {
showStatusSuccess = true;
setTimeout(() => showStatusSuccess = false, 3000);
}
};
}
function handleNoteEnhance() {
submittingNote = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submittingNote = false;
await update({ reset: true });
if (result.type === 'success') {
showNoteSuccess = true;
setTimeout(() => showNoteSuccess = false, 3000);
}
};
}
function handleHomevisitNoteEnhance() {
submittingHomevisitNote = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submittingHomevisitNote = false;
await update({ reset: true });
if (result.type === 'success') {
showHomevisitNoteSuccess = true;
setTimeout(() => showHomevisitNoteSuccess = false, 3000);
}
};
}
const statusOptions = ['pending', 'reviewing', 'approved', 'denied', 'withdrawn'];
const typeColorMap: Record<string, string> = {
adoption: 'bg-blue-100 text-blue-800',
foster: 'bg-purple-100 text-purple-800',
volunteer: 'bg-green-100 text-green-800',
surrender: 'bg-orange-100 text-orange-800'
};
function formatType(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
function formatDate(dateStr: string | Date): string {
const d = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatFieldLabel(key: string): string {
return key.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
}
const formDataEntries = $derived.by(() => {
let fd = app.formData;
if (!fd) return [];
if (typeof fd === 'string') {
try { fd = JSON.parse(fd); } catch { return []; }
}
if (typeof fd !== 'object' || fd === null) return [];
return Object.entries(fd as Record<string, unknown>).filter(
([key, value]) => value !== null && value !== undefined && value !== '' && key !== 'signature'
);
});
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
const selectClass = inputClass;
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
<svelte:head>
<title>Application #{app.id} — Admin</title>
</svelte:head>
<div>
<div class="flex items-center gap-4 mb-6">
<a href="/admin/applications" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900">Application #{app.id}</h1>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {typeColorMap[app.type] ?? 'bg-gray-100 text-gray-800'}">
{formatType(app.type)}
</span>
<StatusBadge status={app.status} />
</div>
</div>
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Applicant Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Applicant Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-400 uppercase tracking-wide">Name</p>
<p class="text-sm font-medium text-gray-900 mt-1">{app.applicantName}</p>
</div>
<div>
<p class="text-xs text-gray-400 uppercase tracking-wide">Email</p>
<p class="text-sm mt-1">
<a href="mailto:{app.applicantEmail}" class="text-teal-brand hover:text-teal-dark">{app.applicantEmail}</a>
</p>
</div>
{#if app.applicantPhone}
<div>
<p class="text-xs text-gray-400 uppercase tracking-wide">Phone</p>
<p class="text-sm mt-1">
<a href="tel:{app.applicantPhone}" class="text-teal-brand hover:text-teal-dark">{app.applicantPhone}</a>
</p>
</div>
{/if}
<div>
<p class="text-xs text-gray-400 uppercase tracking-wide">Submitted</p>
<p class="text-sm text-gray-600 mt-1">{formatDate(app.createdAt)}</p>
</div>
</div>
</div>
<!-- Form Data -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Application Details</h2>
{#if formDataEntries.length === 0}
<p class="text-sm text-gray-400">No form data available.</p>
{:else}
<dl class="space-y-4">
{#each formDataEntries as [key, value]}
<div class="border-b border-gray-soft pb-3 last:border-0 last:pb-0">
<dt class="text-xs text-gray-400 uppercase tracking-wide">{formatFieldLabel(key)}</dt>
<dd class="text-sm text-gray-900 mt-1 whitespace-pre-wrap">
{#if typeof value === 'boolean'}
{value ? 'Yes' : 'No'}
{:else if Array.isArray(value)}
{value.join(', ')}
{:else}
{String(value)}
{/if}
</dd>
</div>
{/each}
</dl>
{@const fd = typeof app.formData === 'string' ? JSON.parse(app.formData as string) : app.formData}
{#if (fd as Record<string, unknown>)?.signature}
<div class="mt-6 pt-4 border-t border-gray-soft">
<p class="text-xs text-gray-400 uppercase tracking-wide mb-2">Signature</p>
<div class="border border-gray-soft rounded-lg p-3 bg-gray-50 inline-block">
<img src={(fd as Record<string, unknown>).signature as string} alt="Applicant signature" class="max-h-24" />
</div>
</div>
{/if}
{/if}
</div>
<!-- Review Notes -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Review Notes</h2>
{#if showNoteSuccess}
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Note added.
</div>
{/if}
{#if app.reviewNotes}
<div class="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-soft">
<pre class="text-sm text-gray-700 whitespace-pre-wrap font-sans leading-relaxed">{app.reviewNotes}</pre>
</div>
{:else}
<p class="text-sm text-gray-400 mb-4">No notes yet.</p>
{/if}
<form method="POST" action="?/addNote" use:enhance={handleNoteEnhance}>
<div>
<label for="note" class={labelClass}>Add Note</label>
<textarea id="note" name="note" rows="3" required class="{inputClass} resize-y" placeholder="Write a note..."></textarea>
</div>
<div class="flex justify-end mt-3">
<button
type="submit"
disabled={submittingNote}
class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer"
>
{submittingNote ? 'Adding...' : 'Add Note'}
</button>
</div>
</form>
</div>
<!-- Homevisit Notes -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Homevisit Notes</h2>
{#if showHomevisitNoteSuccess}
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Homevisit note added.
</div>
{/if}
{#if homevisitNotes.length > 0}
<div class="space-y-4 mb-6">
{#each homevisitNotes as note}
<div class="border-l-4 border-teal-brand pl-4 py-2">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{note.userName}</span>
<span class="text-xs text-gray-400">{formatDate(note.createdAt)}</span>
{#if note.visitDate}
<span class="text-xs text-teal-600">Visit: {formatDate(note.visitDate)}</span>
{/if}
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{note.notes}</p>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-gray-400 mb-4">No homevisit notes yet.</p>
{/if}
<form method="POST" action="?/addHomevisitNote" use:enhance={handleHomevisitNoteEnhance}>
<div class="space-y-3">
<div>
<label for="visit-date" class={labelClass}>Visit Date (optional)</label>
<input type="date" id="visit-date" name="visitDate" class={inputClass} />
</div>
<div>
<label for="homevisit-notes" class={labelClass}>Notes</label>
<textarea id="homevisit-notes" name="notes" rows="3" required class="{inputClass} resize-y" placeholder="Record homevisit observations..."></textarea>
</div>
</div>
<div class="flex justify-end mt-3">
<button
type="submit"
disabled={submittingHomevisitNote}
class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer"
>
{submittingHomevisitNote ? 'Adding...' : 'Add Homevisit Note'}
</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status Update -->
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Update Status</h2>
{#if showStatusSuccess}
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Status updated.
</div>
{/if}
<form method="POST" action="?/updateStatus" use:enhance={handleStatusEnhance}>
<div class="mb-3">
<label for="status" class={labelClass}>Status</label>
<select id="status" name="status" class={selectClass}>
{#each statusOptions as s}
<option value={s} selected={app.status === s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
{/each}
</select>
</div>
<button
type="submit"
disabled={submittingStatus}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer"
>
{submittingStatus ? 'Saving...' : 'Save Status'}
</button>
</form>
</div>
<!-- Linked Pet -->
{#if pet}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Linked Pet</h2>
<a href="/admin/pets/{pet.id}" class="block p-4 rounded-lg bg-teal-50 border border-teal-200 hover:bg-teal-100 transition-colors">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-teal-100 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</div>
<div>
<p class="text-sm font-semibold text-gray-900">{pet.name}</p>
<div class="flex items-center gap-2 mt-0.5">
<StatusBadge status={pet.status} />
{#if pet.breed}
<span class="text-xs text-gray-500">{pet.breed}</span>
{/if}
</div>
</div>
</div>
</a>
</div>
{/if}
<!-- Contract Link -->
{#if app.type === 'adoption'}
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Adoption Contract</h2>
<a
href="/admin/applications/{app.id}/contract"
target="_blank"
class="block w-full px-4 py-2.5 text-center text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
Generate Contract
</a>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,64 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { applications, pets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { generateAdoptionContract } from '$lib/server/contract';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
if (isNaN(id)) error(404, 'Application not found');
const [app] = await db.select().from(applications).where(eq(applications.id, id));
if (!app) error(404, 'Application not found');
if (app.type !== 'adoption') {
error(400, 'Contracts can only be generated for adoption applications');
}
let pet = null;
if (app.petId) {
const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId));
pet = petRecord ?? null;
}
if (!pet) {
error(400, 'No pet linked to this application. Link a pet first before generating a contract.');
}
const fd = app.formData as Record<string, unknown> ?? {};
const address = (fd.address as string) ?? '';
const contractHtml = generateAdoptionContract({
orgName: process.env.ORG_NAME ?? 'Rescue Organization',
orgCity: process.env.ORG_CITY ?? '',
orgState: process.env.ORG_STATE ?? '',
petName: pet.name,
species: pet.species,
breed: pet.breed ?? '',
sex: pet.sex,
age: pet.age ?? '',
adopterName: app.applicantName,
adopterEmail: app.applicantEmail,
adoptionFee: pet.adoptionFee ?? 0,
agreementItems: [
'The adopter agrees to provide proper food, water, shelter, and veterinary care.',
'The adopter agrees not to re-home, sell, or transfer ownership without notifying the rescue.',
'The adopter agrees to keep the pet as an indoor pet (where applicable).',
'The adopter agrees to have the pet spayed/neutered if not already done.',
'The rescue reserves the right to reclaim the animal if terms are not met.'
],
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
});
return {
contractHtml,
applicationId: app.id,
petName: pet.name,
applicantName: app.applicantName
};
};
@@ -0,0 +1,29 @@
<script lang="ts">
let { data } = $props();
</script>
<svelte:head>
<title>Adoption Contract — {data.petName}</title>
<style>
@media print {
.no-print { display: none !important; }
}
</style>
</svelte:head>
<div class="no-print" style="position: fixed; top: 16px; right: 16px; z-index: 100; display: flex; gap: 8px;">
<a
href="/admin/applications/{data.applicationId}"
style="padding: 8px 16px; font-size: 14px; border-radius: 8px; border: 1px solid #d1d5db; background: white; color: #374151; text-decoration: none;"
>
Back to Application
</a>
<button
onclick={() => window.print()}
style="padding: 8px 16px; font-size: 14px; font-weight: 500; border-radius: 8px; background: #018184; color: white; border: none; cursor: pointer;"
>
Print Contract
</button>
</div>
{@html data.contractHtml}