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:
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user