Add admin pet detail pages: profile, photos, medical, fosters (Task 32b)

Pet layout: tabbed nav with counts for photos/medical/fosters, status
badge in header. Detail overview: summary cards and compatibility grid.
Profile: full edit form including adoption info conditional on status,
audit logged. Photos: upload, set primary (star toggle), delete, reorder
with prev/next arrows. Medical: add/delete records grouped by date,
cost stored as cents. Fosters: assign by user account (linked to
foster-role users), set current, delete, audit logged throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:33:24 -05:00
co-authored by Claude Opus 4.6
parent 55c1eb7a9f
commit 63f127dfca
12 changed files with 1707 additions and 0 deletions
@@ -0,0 +1,31 @@
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pets, petPhotos, petMedical, fosterAssignments } from '$lib/server/schema';
import { eq, count } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission, canViewPets } from '$lib/roles';
export const load: LayoutServerLoad = async ({ params, locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
const id = parseInt(params.id, 10);
if (isNaN(id)) error(404, 'Pet not found');
const [pet] = await db.select().from(pets).where(eq(pets.id, id));
if (!pet) error(404, 'Pet not found');
const [photosCount, medicalCount, fostersCount] = await Promise.all([
db.select({ total: count() }).from(petPhotos).where(eq(petPhotos.petId, id)),
db.select({ total: count() }).from(petMedical).where(eq(petMedical.petId, id)),
db.select({ total: count() }).from(fosterAssignments).where(eq(fosterAssignments.petId, id))
]);
return {
pet,
canEditPets: hasPermission(locals.user!.role, 'pets'),
counts: {
photos: photosCount[0]?.total ?? 0,
medical: medicalCount[0]?.total ?? 0,
fosters: fostersCount[0]?.total ?? 0
}
};
};
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { page } from '$app/state';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
let { data, children } = $props();
const tabs = $derived([
{ href: `/admin/pets/${data.pet.id}/profile`, label: 'Profile' },
{ href: `/admin/pets/${data.pet.id}`, label: 'Details', exact: true },
{ href: `/admin/pets/${data.pet.id}/photos`, label: 'Photos', count: data.counts.photos },
{ href: `/admin/pets/${data.pet.id}/medical`, label: 'Medical', count: data.counts.medical },
{ href: `/admin/pets/${data.pet.id}/fosters`, label: 'Fosters', count: data.counts.fosters }
]);
function isActive(tab: { href: string; exact?: boolean }): boolean {
if (tab.exact) {
return page.url.pathname === tab.href;
}
return page.url.pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>{data.pet.name} — {data.orgConfig.orgInfo.name} Admin</title>
</svelte:head>
<div>
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<a href="/admin/pets" 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">{data.pet.name}</h1>
<StatusBadge status={data.pet.status} />
</div>
</div>
<!-- Tab Nav -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex gap-0 -mb-px overflow-x-auto">
{#each tabs as tab}
<a
href={tab.href}
class="px-4 py-3 text-sm font-medium border-b-2 whitespace-nowrap transition-colors {isActive(tab)
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"
>
{tab.label}
{#if tab.count !== undefined}
<span class="ml-1.5 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full {isActive(tab) ? 'bg-teal-100 text-teal-700' : 'bg-gray-100 text-gray-600'}">
{tab.count}
</span>
{/if}
</a>
{/each}
</nav>
</div>
{@render children()}
</div>
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { canViewPets } from '$lib/roles';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
// Pet data comes from layout. This page just shows an overview.
return {};
};
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
let { data } = $props();
const pet = $derived(data.pet);
function formatDate(d: unknown): string {
if (!d) return '—';
const date = d instanceof Date ? d : new Date(String(d));
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<div class="space-y-6">
<!-- Quick summary cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-white rounded-xl border border-gray-200 p-4">
<p class="text-xs text-gray-500 mb-1">Species</p>
<p class="font-semibold text-gray-900 capitalize">{pet.species}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<p class="text-xs text-gray-500 mb-1">Sex</p>
<p class="font-semibold text-gray-900 capitalize">{pet.sex}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<p class="text-xs text-gray-500 mb-1">Breed</p>
<p class="font-semibold text-gray-900">{pet.breed ?? '—'}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4">
<p class="text-xs text-gray-500 mb-1">Age</p>
<p class="font-semibold text-gray-900">{pet.age ?? '—'}</p>
</div>
</div>
<!-- Details -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-base font-semibold text-gray-900 mb-4">Pet Details</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div class="flex justify-between md:block">
<dt class="text-gray-500">Intake Date</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{formatDate(pet.intakeDate)}</dd>
</div>
<div class="flex justify-between md:block">
<dt class="text-gray-500">Weight</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{pet.weight ?? '—'}</dd>
</div>
<div class="flex justify-between md:block">
<dt class="text-gray-500">Color</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{pet.color ?? '—'}</dd>
</div>
<div class="flex justify-between md:block">
<dt class="text-gray-500">Microchip ID</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{pet.microchipId ?? '—'}</dd>
</div>
<div class="flex justify-between md:block">
<dt class="text-gray-500">Featured</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{pet.featured ? 'Yes' : 'No'}</dd>
</div>
<div class="flex justify-between md:block">
<dt class="text-gray-500">Published</dt>
<dd class="font-medium text-gray-900 md:mt-0.5">{pet.published ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
{#if pet.description}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-base font-semibold text-gray-900 mb-3">Description</h2>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{pet.description}</p>
</div>
{/if}
<!-- Compatibility -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-base font-semibold text-gray-900 mb-4">Compatibility & Care</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
{#each [
{ label: 'Good with dogs', value: pet.goodWithDogs },
{ label: 'Good with cats', value: pet.goodWithCats },
{ label: 'Good with kids', value: pet.goodWithKids },
{ label: 'House trained', value: pet.houseTrained },
{ label: 'Spayed/Neutered', value: pet.spayedNeutered },
{ label: 'Microchipped', value: pet.microchipped }
] as item}
<div class="flex items-center gap-2">
{#if item.value === true}
<span class="text-green-500">✓</span>
{:else if item.value === false}
<span class="text-red-400">✗</span>
{:else}
<span class="text-gray-300">—</span>
{/if}
<span class="{item.value === true ? 'text-gray-900' : 'text-gray-400'}">{item.label}</span>
</div>
{/each}
</div>
</div>
<!-- Quick links to sub-pages -->
<div class="flex gap-3 flex-wrap">
<a href="/admin/pets/{pet.id}/profile" class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 transition-colors">
Edit Profile
</a>
<a href="/admin/pets/{pet.id}/photos" class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors">
Manage Photos ({data.counts.photos})
</a>
<a href="/admin/pets/{pet.id}/medical" class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors">
Medical Records ({data.counts.medical})
</a>
<a href="/admin/pets/{pet.id}/fosters" class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors">
Foster History ({data.counts.fosters})
</a>
</div>
</div>
@@ -0,0 +1,137 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { fosterAssignments, users } from '$lib/server/schema';
import { eq, and, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { logAudit } from '$lib/server/audit';
import { hasPermission, canViewPets } from '$lib/roles';
export const load: PageServerLoad = async ({ parent, locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
const { pet } = await parent();
const assignments = await db
.select({
id: fosterAssignments.id,
petId: fosterAssignments.petId,
userId: fosterAssignments.userId,
startDate: fosterAssignments.startDate,
endDate: fosterAssignments.endDate,
isCurrent: fosterAssignments.isCurrent,
notes: fosterAssignments.notes,
createdAt: fosterAssignments.createdAt,
fosterName: users.name,
fosterEmail: users.email
})
.from(fosterAssignments)
.leftJoin(users, eq(fosterAssignments.userId, users.id))
.where(eq(fosterAssignments.petId, pet.id))
.orderBy(asc(fosterAssignments.startDate));
// Load active foster-role users for the dropdown
const fosterUsers = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(and(eq(users.role, 'foster'), eq(users.active, true)))
.orderBy(asc(users.name));
return { assignments, fosterUsers };
};
export const actions: Actions = {
add: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const userIdStr = fd.get('userId')?.toString();
const userId = userIdStr ? parseInt(userIdStr, 10) : null;
if (!userId || isNaN(userId)) {
return fail(400, { error: 'Foster user is required' });
}
const startDateStr = fd.get('startDate')?.toString() || null;
const startDate = startDateStr ? new Date(startDateStr) : new Date();
const endDateStr = fd.get('endDate')?.toString() || null;
const endDate = endDateStr ? new Date(endDateStr) : null;
const notes = fd.get('notes')?.toString().trim() || null;
const isCurrent = fd.get('isCurrent') === 'on';
// If marking as current, unset all others
if (isCurrent) {
await db.update(fosterAssignments).set({ isCurrent: false }).where(eq(fosterAssignments.petId, petId));
}
const [inserted] = await db.insert(fosterAssignments).values({
petId,
userId,
startDate,
endDate,
notes,
isCurrent
}).$returningId();
await logAudit({
userId: locals.user!.id,
action: 'create',
entity: 'foster_assignment',
entityId: inserted.id,
diff: { petId, userId, isCurrent }
});
return { success: true };
},
setCurrent: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const assignmentId = parseInt(fd.get('assignmentId') as string, 10);
if (!assignmentId || isNaN(assignmentId)) {
return fail(400, { error: 'Invalid assignment ID' });
}
const isCurrent = fd.get('isCurrent') === 'on';
if (isCurrent) {
await db.update(fosterAssignments).set({ isCurrent: false }).where(eq(fosterAssignments.petId, petId));
}
await db.update(fosterAssignments).set({ isCurrent }).where(eq(fosterAssignments.id, assignmentId));
await logAudit({
userId: locals.user!.id,
action: 'update',
entity: 'foster_assignment',
entityId: assignmentId,
diff: { petId, isCurrent }
});
return { success: true };
},
delete: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const assignmentId = parseInt(fd.get('assignmentId') as string, 10);
if (!assignmentId || isNaN(assignmentId)) {
return fail(400, { error: 'Invalid assignment ID' });
}
await db.delete(fosterAssignments).where(eq(fosterAssignments.id, assignmentId));
await logAudit({
userId: locals.user!.id,
action: 'delete',
entity: 'foster_assignment',
entityId: assignmentId,
diff: { petId }
});
return { success: true };
}
};
@@ -0,0 +1,223 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
let { data, form } = $props();
const assignments = $derived(data.assignments);
const fosterUsers = $derived(data.fosterUsers);
let submitting = $state(false);
let showSuccess = $state(false);
let deleteId = $state<number | null>(null);
let showDeleteConfirm = $state(false);
function handleEnhance() {
submitting = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = false;
await update({ reset: true });
if (result.type === 'success') {
showSuccess = true;
setTimeout(() => (showSuccess = false), 3000);
}
};
}
function handleUpdateEnhance() {
return async ({ update }: { update: (opts?: { reset?: boolean }) => Promise<void> }) => {
await update({ reset: false });
};
}
function confirmDelete(id: number) {
deleteId = id;
showDeleteConfirm = true;
}
function formatDate(dateVal: Date | string | null): string {
if (!dateVal) return '—';
const d = dateVal instanceof Date ? dateVal : new Date(dateVal);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl 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>
Foster assignment saved successfully.
</div>
{/if}
{#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}
<!-- Add Foster Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Add Foster Placement</h2>
<form method="POST" action="?/add" use:enhance={handleEnhance} class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="md:col-span-2">
<label for="userId" class={labelClass}>Foster User <span class="text-red-500">*</span></label>
<select id="userId" name="userId" required class={inputClass}>
<option value="">Select foster...</option>
{#each fosterUsers as fu}
<option value={fu.id}>{fu.name} ({fu.email})</option>
{/each}
</select>
{#if fosterUsers.length === 0}
<p class="mt-1 text-xs text-gray-400">No foster-role users found. Add users with the "foster" role in User Management.</p>
{/if}
</div>
<div>
<label for="startDate" class={labelClass}>Start Date</label>
<input type="date" id="startDate" name="startDate" class={inputClass} />
</div>
<div>
<label for="endDate" class={labelClass}>End Date</label>
<input type="date" id="endDate" name="endDate" class={inputClass} />
</div>
</div>
<div>
<label for="notes" class={labelClass}>Notes</label>
<textarea id="notes" name="notes" rows="2" class="{inputClass} resize-y" placeholder="Any notes about this foster placement..."></textarea>
</div>
<div class="flex items-center justify-between">
<label class="inline-flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="isCurrent" class="w-5 h-5 rounded border-gray-200 text-teal-600 focus:ring-teal-600" />
<span class="text-sm font-medium text-gray-700">Current foster placement</span>
</label>
<button
type="submit"
disabled={submitting}
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Adding...' : 'Add Foster'}
</button>
</div>
</form>
</div>
<!-- Foster Assignments List -->
{#if assignments.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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="text-sm font-medium">No foster placements yet</p>
<p class="text-xs mt-1">Add the first placement using the form above.</p>
</div>
{:else}
<div class="space-y-4">
{#each assignments as assignment}
<div class="bg-white rounded-xl shadow-sm border-2 {assignment.isCurrent ? 'border-green-400' : 'border-gray-200'} p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-base font-semibold text-gray-900">{assignment.fosterName ?? 'Unknown User'}</h3>
{#if assignment.isCurrent}
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-green-100 text-green-800 ring-1 ring-inset ring-green-600/20">
Current
</span>
{/if}
{#if assignment.fosterEmail}
<span class="text-xs text-gray-400">{assignment.fosterEmail}</span>
{/if}
</div>
<div class="flex items-center gap-4 text-sm text-gray-500 mb-2">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{formatDate(assignment.startDate)}
{#if assignment.endDate}
<span class="mx-1">to</span> {formatDate(assignment.endDate)}
{:else}
<span class="mx-1 text-gray-400">— ongoing</span>
{/if}
</span>
</div>
{#if assignment.notes}
<p class="text-sm text-gray-600">{assignment.notes}</p>
{/if}
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Toggle Current -->
<form method="POST" action="?/setCurrent" use:enhance={handleUpdateEnhance}>
<input type="hidden" name="assignmentId" value={assignment.id} />
<input type="hidden" name="isCurrent" value={assignment.isCurrent ? '' : 'on'} />
<button
type="submit"
class="p-2 rounded-lg text-gray-400 hover:text-teal-600 hover:bg-teal-50 transition-colors cursor-pointer"
title={assignment.isCurrent ? 'Remove as current' : 'Set as current'}
>
{#if assignment.isCurrent}
<svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{:else}
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{/if}
</button>
</form>
<!-- Delete -->
<button
type="button"
onclick={() => confirmDelete(assignment.id)}
class="p-2 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
aria-label="Delete foster assignment"
>
<svg class="w-4 h-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>
</div>
</div>
</div>
{/each}
</div>
{/if}
<ConfirmModal
open={showDeleteConfirm}
title="Delete Foster Assignment"
message="Are you sure you want to delete this foster assignment? This action cannot be undone."
confirmText="Delete"
onconfirm={() => {
if (deleteId) {
const frm = document.createElement('form');
frm.method = 'POST';
frm.action = '?/delete';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'assignmentId';
input.value = String(deleteId);
frm.appendChild(input);
document.body.appendChild(frm);
frm.submit();
}
showDeleteConfirm = false;
}}
oncancel={() => { showDeleteConfirm = false; }}
/>
@@ -0,0 +1,99 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { petMedical, vets } from '$lib/server/schema';
import { eq, desc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { logAudit } from '$lib/server/audit';
import { hasPermission, canViewPets } from '$lib/roles';
export const load: PageServerLoad = async ({ parent, locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
const { pet } = await parent();
const [records, vetList] = await Promise.all([
db
.select({
id: petMedical.id,
petId: petMedical.petId,
vetId: petMedical.vetId,
date: petMedical.date,
type: petMedical.type,
name: petMedical.name,
cost: petMedical.cost,
notes: petMedical.notes,
createdAt: petMedical.createdAt,
vetName: vets.name,
vetClinic: vets.clinic
})
.from(petMedical)
.leftJoin(vets, eq(petMedical.vetId, vets.id))
.where(eq(petMedical.petId, pet.id))
.orderBy(desc(petMedical.date), desc(petMedical.createdAt)),
db.select().from(vets).where(eq(vets.active, true))
]);
return { records, vets: vetList };
};
export const actions: Actions = {
add: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'medical')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const type = fd.get('type')?.toString();
const name = fd.get('name')?.toString().trim();
if (!type) return fail(400, { error: 'Type is required' });
if (!name) return fail(400, { error: 'Name is required' });
const dateStr = fd.get('date')?.toString();
const date = dateStr ? new Date(dateStr) : null;
const vetIdStr = fd.get('vetId')?.toString();
const vetId = vetIdStr ? parseInt(vetIdStr, 10) : null;
const costStr = fd.get('cost')?.toString().trim();
const cost = costStr ? Math.round(parseFloat(costStr) * 100) : null;
const notes = fd.get('notes')?.toString().trim() || null;
const [inserted] = await db.insert(petMedical).values({
petId,
vetId: vetId && !isNaN(vetId) ? vetId : null,
date,
type: type as typeof petMedical.type.enumValues[number],
name,
cost,
notes
}).$returningId();
await logAudit({
userId: locals.user!.id,
action: 'create',
entity: 'pet_medical',
entityId: inserted.id,
diff: { petId, type, name, date: dateStr }
});
return { success: true };
},
delete: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'medical')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const recordId = parseInt(fd.get('recordId') as string, 10);
if (!recordId || isNaN(recordId)) return fail(400, { error: 'Invalid record ID' });
await db.delete(petMedical).where(eq(petMedical.id, recordId));
await logAudit({
userId: locals.user!.id,
action: 'delete',
entity: 'pet_medical',
entityId: recordId,
diff: { petId }
});
return { success: true };
}
};
@@ -0,0 +1,257 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
let { data, form } = $props();
const records = $derived(data.records);
const vets = $derived(data.vets);
const medicalTypes = [
{ value: 'vaccine', label: 'Vaccine' },
{ value: 'surgery', label: 'Surgery' },
{ value: 'checkup', label: 'Checkup' },
{ value: 'medication', label: 'Medication' },
{ value: 'deworming', label: 'Deworming' },
{ value: 'flea_tick', label: 'Flea/Tick' },
{ value: 'heartworm_test', label: 'Heartworm Test' },
{ value: 'heartworm_treatment', label: 'Heartworm Treatment' },
{ value: 'dental', label: 'Dental' },
{ value: 'spay_neuter', label: 'Spay/Neuter' },
{ value: 'microchip', label: 'Microchip' },
{ value: 'other', label: 'Other' }
];
let submitting = $state(false);
let showSuccess = $state(false);
let deleteId = $state<number | null>(null);
let showDeleteConfirm = $state(false);
function handleEnhance() {
submitting = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = false;
await update({ reset: true });
if (result.type === 'success') {
showSuccess = true;
setTimeout(() => (showSuccess = false), 3000);
}
};
}
function confirmDelete(id: number) {
deleteId = id;
showDeleteConfirm = true;
}
const typeColorMap: Record<string, string> = {
vaccine: 'bg-blue-100 text-blue-800',
surgery: 'bg-red-100 text-red-800',
checkup: 'bg-green-100 text-green-800',
medication: 'bg-purple-100 text-purple-800',
deworming: 'bg-orange-100 text-orange-800',
flea_tick: 'bg-amber-100 text-amber-800',
heartworm_test: 'bg-pink-100 text-pink-800',
heartworm_treatment: 'bg-rose-100 text-rose-800',
dental: 'bg-indigo-100 text-indigo-800',
spay_neuter: 'bg-rose-100 text-rose-800',
microchip: 'bg-cyan-100 text-cyan-800',
other: 'bg-gray-100 text-gray-800'
};
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function formatDate(d: Date | string | null): string {
if (!d) return '—';
const date = d instanceof Date ? d : new Date(d);
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
}
function formatCost(cents: number | null): string {
if (cents == null) return '';
return `$${(cents / 100).toFixed(2)}`;
}
// Group records by date
const groupedRecords = $derived(() => {
const groups: Map<string, typeof records> = new Map();
for (const record of records) {
const dateKey = record.date
? (record.date instanceof Date ? record.date : new Date(record.date)).toISOString().split('T')[0]
: 'unknown';
if (!groups.has(dateKey)) groups.set(dateKey, []);
groups.get(dateKey)!.push(record);
}
return groups;
});
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl 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>
Medical record added successfully.
</div>
{/if}
{#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}
<!-- Add Medical Record Form -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Add Medical Record</h2>
<form method="POST" action="?/add" use:enhance={handleEnhance} class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="type" class={labelClass}>Type <span class="text-red-500">*</span></label>
<select id="type" name="type" required class={inputClass}>
{#each medicalTypes as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</div>
<div>
<label for="name" class={labelClass}>Name / Product <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required class={inputClass} placeholder="e.g., Bordetella, Heartgard" />
</div>
<div>
<label for="date" class={labelClass}>Date</label>
<input type="date" id="date" name="date" class={inputClass} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="vetId" class={labelClass}>Vet</label>
<select id="vetId" name="vetId" class={inputClass}>
<option value="">Select vet</option>
{#each vets as vet}
<option value={vet.id}>{vet.name}{vet.clinic ? ` — ${vet.clinic}` : ''}</option>
{/each}
</select>
</div>
<div>
<label for="cost" class={labelClass}>Cost ($)</label>
<input type="text" id="cost" name="cost" class={inputClass} placeholder="0.00" />
</div>
</div>
<div>
<label for="notes" class={labelClass}>Notes</label>
<textarea id="notes" name="notes" rows="2" class="{inputClass} resize-y" placeholder="Additional details..."></textarea>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={submitting}
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Adding...' : 'Add Record'}
</button>
</div>
</form>
</div>
<!-- Medical Records Timeline -->
{#if records.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">No medical records yet</p>
<p class="text-xs mt-1">Add the first record using the form above.</p>
</div>
{:else}
<div class="space-y-6">
{#each [...groupedRecords().entries()] as [dateKey, dateRecords]}
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{formatDate(dateKey === 'unknown' ? null : dateKey)}
</h3>
<div class="space-y-3">
{#each dateRecords as record}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {typeColorMap[record.type] ?? 'bg-gray-100 text-gray-800'}">
{formatType(record.type)}
</span>
<span class="text-sm font-medium text-gray-700">{record.name}</span>
</div>
{#if record.notes}
<p class="text-sm text-gray-600 mb-2">{record.notes}</p>
{/if}
<div class="flex items-center gap-4 text-xs text-gray-400 flex-wrap">
{#if record.vetName}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{record.vetName}{record.vetClinic ? ` (${record.vetClinic})` : ''}
</span>
{/if}
{#if record.cost}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatCost(record.cost)}
</span>
{/if}
</div>
</div>
<button
type="button"
onclick={() => confirmDelete(record.id)}
class="flex-shrink-0 p-2 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
aria-label="Delete record"
>
<svg class="w-4 h-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>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
<ConfirmModal
open={showDeleteConfirm}
title="Delete Medical Record"
message="Are you sure you want to delete this medical record? This action cannot be undone."
confirmText="Delete"
onconfirm={() => {
if (deleteId) {
const frm = document.createElement('form');
frm.method = 'POST';
frm.action = '?/delete';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'recordId';
input.value = String(deleteId);
frm.appendChild(input);
document.body.appendChild(frm);
frm.submit();
}
showDeleteConfirm = false;
}}
oncancel={() => { showDeleteConfirm = false; }}
/>
@@ -0,0 +1,149 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { petPhotos } from '$lib/server/schema';
import { eq, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { saveUpload, deleteUpload } from '$lib/server/upload';
import { logAudit } from '$lib/server/audit';
import { hasPermission, canViewPets } from '$lib/roles';
export const load: PageServerLoad = async ({ parent, locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
const { pet } = await parent();
const photos = await db
.select()
.from(petPhotos)
.where(eq(petPhotos.petId, pet.id))
.orderBy(asc(petPhotos.sortOrder));
return { photos };
};
export const actions: Actions = {
upload: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const file = fd.get('photo') as File | null;
if (!file || file.size === 0) {
return fail(400, { error: 'No file selected' });
}
let url: string;
try {
const result = await saveUpload(file, 'pets');
url = result.url;
} catch (err) {
return fail(400, { error: (err as Error).message });
}
const existing = await db
.select({ sortOrder: petPhotos.sortOrder })
.from(petPhotos)
.where(eq(petPhotos.petId, petId))
.orderBy(asc(petPhotos.sortOrder));
const nextSort = existing.length > 0 ? Math.max(...existing.map((e) => e.sortOrder)) + 1 : 0;
const isPrimary = existing.length === 0;
const [inserted] = await db.insert(petPhotos).values({
petId,
url,
isPrimary,
sortOrder: nextSort
}).$returningId();
await logAudit({
userId: locals.user!.id,
action: 'create',
entity: 'pet_photo',
entityId: inserted.id,
diff: { petId, url }
});
return { success: true };
},
setPrimary: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const photoId = parseInt(fd.get('photoId') as string, 10);
if (!photoId || isNaN(photoId)) return fail(400, { error: 'Invalid photo ID' });
await db.update(petPhotos).set({ isPrimary: false }).where(eq(petPhotos.petId, petId));
await db.update(petPhotos).set({ isPrimary: true }).where(eq(petPhotos.id, photoId));
await logAudit({
userId: locals.user!.id,
action: 'update',
entity: 'pet_photo',
entityId: photoId,
diff: { petId, action: 'set_primary' }
});
return { success: true };
},
delete: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const photoId = parseInt(fd.get('photoId') as string, 10);
if (!photoId || isNaN(photoId)) return fail(400, { error: 'Invalid photo ID' });
const [photo] = await db.select().from(petPhotos).where(eq(petPhotos.id, photoId));
if (!photo) return fail(404, { error: 'Photo not found' });
await db.delete(petPhotos).where(eq(petPhotos.id, photoId));
try {
await deleteUpload(photo.url);
} catch {
// File may not exist on disk
}
await logAudit({
userId: locals.user!.id,
action: 'delete',
entity: 'pet_photo',
entityId: photoId,
diff: { petId, url: photo.url }
});
return { success: true };
},
reorder: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const petId = parseInt(params.id, 10);
const fd = await request.formData();
const orderJson = fd.get('order')?.toString();
if (!orderJson) return fail(400, { error: 'No order data provided' });
let items: { id: number; sortOrder: number }[];
try {
items = JSON.parse(orderJson);
} catch {
return fail(400, { error: 'Invalid order data' });
}
for (const item of items) {
await db.update(petPhotos).set({ sortOrder: item.sortOrder }).where(eq(petPhotos.id, item.id));
}
await logAudit({
userId: locals.user!.id,
action: 'update',
entity: 'pet_photo',
diff: { petId, action: 'reorder', items }
});
return { success: true };
}
};
@@ -0,0 +1,209 @@
<script lang="ts">
import { enhance } from '$app/forms';
import FileUpload from '$lib/components/ui/FileUpload.svelte';
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
let { data, form } = $props();
const photos = $derived(data.photos);
const canEdit = $derived(data.canEditPets);
let submitting = $state(false);
let showSuccess = $state(false);
let deleteId = $state<number | null>(null);
let showDeleteConfirm = $state(false);
function handleUploadEnhance() {
submitting = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = false;
await update({ reset: true });
if (result.type === 'success') {
showSuccess = true;
setTimeout(() => (showSuccess = false), 3000);
}
};
}
function handleActionEnhance() {
return async ({ update }: { update: (opts?: { reset?: boolean }) => Promise<void> }) => {
await update({ reset: false });
};
}
function confirmDelete(id: number) {
deleteId = id;
showDeleteConfirm = true;
}
function movePhoto(photoId: number, direction: 'up' | 'down') {
const currentIndex = photos.findIndex((p) => p.id === photoId);
if (currentIndex === -1) return;
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= photos.length) return;
const reordered = [...photos];
[reordered[currentIndex], reordered[targetIndex]] = [reordered[targetIndex], reordered[currentIndex]];
const orderData = reordered.map((p, i) => ({ id: p.id, sortOrder: i }));
const frm = document.createElement('form');
frm.method = 'POST';
frm.action = '?/reorder';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'order';
input.value = JSON.stringify(orderData);
frm.appendChild(input);
document.body.appendChild(frm);
frm.submit();
}
</script>
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl 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>
Photo uploaded successfully.
</div>
{/if}
{#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}
{#if canEdit}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Upload Photo</h2>
<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance={handleUploadEnhance}>
<FileUpload name="photo" accept="image/*" />
<div class="flex justify-end mt-4">
<button
type="submit"
disabled={submitting}
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Uploading...' : 'Upload Photo'}
</button>
</div>
</form>
</div>
{/if}
<!-- Photo Grid -->
{#if photos.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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p class="text-sm font-medium">No photos yet</p>
{#if canEdit}<p class="text-xs mt-1">Upload the first photo using the form above.</p>{/if}
</div>
{:else}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each photos as photo, index}
<div class="relative group bg-white rounded-xl shadow-sm border-2 {photo.isPrimary ? 'border-yellow-400' : 'border-gray-200'} overflow-hidden">
<div class="aspect-square">
<img src={photo.url} alt={photo.alt ?? `Pet photo ${index + 1}`} class="w-full h-full object-cover" />
</div>
{#if canEdit}
<!-- Overlay Controls -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-start justify-between p-2 opacity-0 group-hover:opacity-100">
<!-- Primary Star -->
<form method="POST" action="?/setPrimary" use:enhance={handleActionEnhance}>
<input type="hidden" name="photoId" value={photo.id} />
<button
type="submit"
class="p-1.5 rounded-lg bg-white/90 hover:bg-white shadow-sm transition-colors cursor-pointer"
title={photo.isPrimary ? 'Primary photo' : 'Set as primary'}
>
{#if photo.isPrimary}
<svg class="w-5 h-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{:else}
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{/if}
</button>
</form>
<!-- Delete -->
<button
type="button"
onclick={() => confirmDelete(photo.id)}
class="p-1.5 rounded-lg bg-white/90 hover:bg-white shadow-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer"
title="Delete photo"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Sort Buttons -->
<div class="absolute bottom-0 inset-x-0 bg-white/90 backdrop-blur-sm p-2 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity">
<div class="flex gap-1">
<button
type="button"
onclick={() => movePhoto(photo.id, 'up')}
disabled={index === 0}
class="p-1 rounded text-gray-500 hover:text-teal-600 hover:bg-teal-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors cursor-pointer"
title="Move left"
>
<svg class="w-4 h-4" 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>
</button>
<button
type="button"
onclick={() => movePhoto(photo.id, 'down')}
disabled={index === photos.length - 1}
class="p-1 rounded text-gray-500 hover:text-teal-600 hover:bg-teal-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors cursor-pointer"
title="Move right"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{#if photo.isPrimary}
<span class="text-xs font-semibold text-yellow-600">Primary</span>
{:else}
<span class="text-xs text-gray-400">#{index + 1}</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{#if canEdit}
<ConfirmModal
open={showDeleteConfirm}
title="Delete Photo"
message="Are you sure you want to delete this photo? The file will be permanently removed."
confirmText="Delete"
onconfirm={() => {
if (deleteId) {
const frm = document.createElement('form');
frm.method = 'POST';
frm.action = '?/delete';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'photoId';
input.value = String(deleteId);
frm.appendChild(input);
document.body.appendChild(frm);
frm.submit();
}
showDeleteConfirm = false;
}}
oncancel={() => { showDeleteConfirm = false; }}
/>
{/if}
@@ -0,0 +1,132 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { pets, intakeSources, vets } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import { slugify } from '$lib/slugify';
import { hasPermission, canViewPets } from '$lib/roles';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ parent, locals }) => {
if (!canViewPets(locals.user!.role)) error(403, 'Access denied');
await parent();
const [sources, vetList] = await Promise.all([
db.select().from(intakeSources).where(eq(intakeSources.active, true)),
db.select().from(vets).where(eq(vets.active, true))
]);
return {
intakeSources: sources,
vets: vetList
};
};
export const actions: Actions = {
default: async ({ request, params, locals }) => {
if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied');
const id = parseInt(params.id, 10);
const fd = await request.formData();
const name = fd.get('name')?.toString().trim() ?? '';
const species = fd.get('species')?.toString() ?? 'dog';
if (!name) return fail(400, { error: 'Name is required' });
if (!['dog', 'cat', 'other'].includes(species)) return fail(400, { error: 'Invalid species' });
// Regenerate slug only if name changed
const [existing] = await db.select({ name: pets.name, slug: pets.slug }).from(pets).where(eq(pets.id, id));
let slug = existing?.slug ?? slugify(name);
if (existing && existing.name !== name) {
const newSlug = slugify(name);
const [conflict] = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, newSlug));
if (conflict && conflict.id !== id) {
slug = `${newSlug}-${Date.now()}`;
} else {
slug = newSlug;
}
}
const breed = fd.get('breed')?.toString().trim() || null;
const sex = fd.get('sex')?.toString() || 'unknown';
const age = fd.get('age')?.toString().trim() || null;
const birthdayStr = fd.get('birthday')?.toString() || null;
const birthday = birthdayStr ? new Date(birthdayStr) : null;
const weight = fd.get('weight')?.toString().trim() || null;
const color = fd.get('color')?.toString().trim() || null;
const description = fd.get('description')?.toString().trim() || null;
const medicalNotes = fd.get('medicalNotes')?.toString().trim() || null;
const behavioralNotes = fd.get('behavioralNotes')?.toString().trim() || null;
const specialNeeds = fd.get('specialNeeds')?.toString().trim() || null;
const intakeDateStr = fd.get('intakeDate')?.toString() || null;
const intakeDate = intakeDateStr ? new Date(intakeDateStr) : null;
const intakeSourceId = fd.get('intakeSourceId')?.toString()
? parseInt(fd.get('intakeSourceId') as string, 10)
: null;
const vetIdStr = fd.get('vetId')?.toString();
const vetId = vetIdStr ? parseInt(vetIdStr, 10) : null;
const status = fd.get('status')?.toString() || 'available';
const featured = fd.get('featured') === 'on';
const published = fd.get('published') === 'on';
const goodWithDogs = fd.has('goodWithDogs') ? fd.get('goodWithDogs') === 'on' : null;
const goodWithCats = fd.has('goodWithCats') ? fd.get('goodWithCats') === 'on' : null;
const goodWithKids = fd.has('goodWithKids') ? fd.get('goodWithKids') === 'on' : null;
const houseTrained = fd.get('houseTrained') === 'on' ? true : null;
const spayedNeutered = fd.get('spayedNeutered') === 'on' ? true : null;
const microchipped = fd.get('microchipped') === 'on' ? true : null;
const microchipId = fd.get('microchipId')?.toString().trim() || null;
const adoptedDateStr = fd.get('adoptedDate')?.toString() || null;
const adoptedDate = adoptedDateStr ? new Date(adoptedDateStr) : null;
const adopterName = fd.get('adopterName')?.toString().trim() || null;
const adopterEmail = fd.get('adopterEmail')?.toString().trim() || null;
const adopterPhone = fd.get('adopterPhone')?.toString().trim() || null;
const adoptionFeeStr = fd.get('adoptionFee')?.toString().trim();
const adoptionFee = adoptionFeeStr ? Math.round(parseFloat(adoptionFeeStr) * 100) : null;
const adoptionFeeCollected = fd.get('adoptionFeeCollected') === 'on';
await db.update(pets).set({
name,
species: species as 'dog' | 'cat' | 'other',
breed,
sex: sex as 'male' | 'female' | 'unknown',
age,
birthday,
weight,
color,
description,
medicalNotes,
behavioralNotes,
specialNeeds,
slug,
intakeDate,
intakeSourceId,
vetId: vetId && !isNaN(vetId) ? vetId : null,
status: status as typeof pets.status.enumValues[number],
featured,
published,
goodWithDogs,
goodWithCats,
goodWithKids,
houseTrained,
spayedNeutered,
microchipped,
microchipId,
adoptedDate,
adopterName,
adopterEmail,
adopterPhone,
adoptionFee,
adoptionFeeCollected
}).where(eq(pets.id, id));
await logAudit({
userId: locals.user!.id,
action: 'update',
entity: 'pet',
entityId: id,
diff: { name, status }
});
return { success: true };
}
};
@@ -0,0 +1,284 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
const pet = $derived(data.pet);
const canEdit = $derived(data.canEditPets);
const allStatuses = [
'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold',
'pending', 'trial', 'hospice', 'nursing', 'whelping',
'deceased', 'transferred'
];
const showAdoption = $derived(['adopted', 'pending', 'trial'].includes(pet.status));
let submitting = $state(false);
let showSuccess = $state(false);
function handleEnhance() {
submitting = true;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = false;
await update({ reset: false });
if (result.type === 'success') {
showSuccess = true;
setTimeout(() => (showSuccess = false), 3000);
}
};
}
function dateVal(d: Date | string | null | undefined): string {
if (!d) return '';
const date = d instanceof Date ? d : new Date(d);
if (isNaN(date.getTime())) return '';
return date.toISOString().split('T')[0];
}
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 text-sm';
const selectClass = inputClass;
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
</script>
{#if showSuccess}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl 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>
Pet updated successfully.
</div>
{/if}
{#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}
<form method="POST" use:enhance={handleEnhance} class="space-y-6">
<fieldset disabled={!canEdit} class={!canEdit ? 'opacity-75' : ''}>
<!-- Basic Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label for="name" class={labelClass}>Name <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required value={pet.name} class={inputClass} />
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">Species <span class="text-red-500">*</span></label>
<div class="flex gap-6">
{#each ['dog', 'cat', 'other'] as s}
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="species" value={s} checked={pet.species === s} class="w-4 h-4 text-teal-600 focus:ring-teal-600 border-gray-200" />
<span class="text-sm text-gray-700 capitalize">{s}</span>
</label>
{/each}
</div>
</div>
<div>
<label for="breed" class={labelClass}>Breed</label>
<input type="text" id="breed" name="breed" value={pet.breed ?? ''} class={inputClass} placeholder="e.g., Labrador Mix" />
</div>
<div>
<label for="sex" class={labelClass}>Sex</label>
<select id="sex" name="sex" class={selectClass}>
<option value="unknown" selected={pet.sex === 'unknown'}>Unknown</option>
<option value="male" selected={pet.sex === 'male'}>Male</option>
<option value="female" selected={pet.sex === 'female'}>Female</option>
</select>
</div>
<div>
<label for="age" class={labelClass}>Age Estimate</label>
<input type="text" id="age" name="age" value={pet.age ?? ''} class={inputClass} placeholder="e.g., 2 years" />
</div>
<div>
<label for="birthday" class={labelClass}>Birthday</label>
<input type="date" id="birthday" name="birthday" value={dateVal(pet.birthday)} class={inputClass} />
</div>
<div>
<label for="weight" class={labelClass}>Weight</label>
<input type="text" id="weight" name="weight" value={pet.weight ?? ''} class={inputClass} placeholder="e.g., 45 lbs" />
</div>
<div>
<label for="color" class={labelClass}>Color / Markings</label>
<input type="text" id="color" name="color" value={pet.color ?? ''} class={inputClass} />
</div>
<div class="md:col-span-2">
<label for="description" class={labelClass}>Description</label>
<textarea id="description" name="description" rows="4" class="{inputClass} resize-y">{pet.description ?? ''}</textarea>
</div>
</div>
</div>
<!-- Compatibility & Care -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Compatibility & Care</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
{#each [
{ name: 'goodWithDogs', label: 'Good with dogs', value: pet.goodWithDogs },
{ name: 'goodWithCats', label: 'Good with cats', value: pet.goodWithCats },
{ name: 'goodWithKids', label: 'Good with kids', value: pet.goodWithKids },
{ name: 'houseTrained', label: 'House trained', value: pet.houseTrained },
{ name: 'spayedNeutered', label: 'Spayed/Neutered', value: pet.spayedNeutered },
{ name: 'microchipped', label: 'Microchipped', value: pet.microchipped }
] as item}
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name={item.name} checked={item.value ?? false} class="w-4 h-4 rounded border-gray-200 text-teal-600 focus:ring-teal-600" />
<span class="text-sm text-gray-700">{item.label}</span>
</label>
{/each}
</div>
<div class="mt-4">
<label for="microchipId" class={labelClass}>Microchip ID</label>
<input type="text" id="microchipId" name="microchipId" value={pet.microchipId ?? ''} class="{inputClass} max-w-xs" />
</div>
</div>
<!-- Notes -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Notes</h2>
<div class="space-y-4">
<div>
<label for="medicalNotes" class={labelClass}>Medical Notes</label>
<textarea id="medicalNotes" name="medicalNotes" rows="3" class="{inputClass} resize-y">{pet.medicalNotes ?? ''}</textarea>
</div>
<div>
<label for="behavioralNotes" class={labelClass}>Behavioral Notes</label>
<textarea id="behavioralNotes" name="behavioralNotes" rows="3" class="{inputClass} resize-y">{pet.behavioralNotes ?? ''}</textarea>
</div>
<div>
<label for="specialNeeds" class={labelClass}>Special Needs</label>
<textarea id="specialNeeds" name="specialNeeds" rows="2" class="{inputClass} resize-y">{pet.specialNeeds ?? ''}</textarea>
</div>
</div>
</div>
<!-- Intake Information -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Intake Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="intakeDate" class={labelClass}>Intake Date</label>
<input type="date" id="intakeDate" name="intakeDate" value={dateVal(pet.intakeDate)} class={inputClass} />
</div>
<div>
<label for="intakeSourceId" class={labelClass}>Intake Source</label>
<select id="intakeSourceId" name="intakeSourceId" class={selectClass}>
<option value="">Select source</option>
{#each data.intakeSources as source}
<option value={source.id} selected={pet.intakeSourceId === source.id}>{source.name}</option>
{/each}
</select>
</div>
<div>
<label for="vetId" class={labelClass}>Vet Clinic</label>
<select id="vetId" name="vetId" class={selectClass}>
<option value="">Select vet</option>
{#each data.vets as vet}
<option value={vet.id} selected={pet.vetId === vet.id}>{vet.name}{vet.clinic ? ` — ${vet.clinic}` : ''}</option>
{/each}
</select>
</div>
</div>
</div>
<!-- Settings -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="status" class={labelClass}>Status</label>
<select id="status" name="status" class={selectClass}>
{#each allStatuses as s}
<option value={s} selected={pet.status === s}>{s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-3 pt-1">
<label class="inline-flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="featured" checked={pet.featured} class="w-5 h-5 rounded border-gray-200 text-teal-600 focus:ring-teal-600" />
<span class="text-sm font-medium text-gray-700">Featured on homepage</span>
</label>
<label class="inline-flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="published" checked={pet.published} class="w-5 h-5 rounded border-gray-200 text-teal-600 focus:ring-teal-600" />
<span class="text-sm font-medium text-gray-700">Published (visible publicly)</span>
</label>
</div>
</div>
</div>
<!-- Adoption Info (conditional) -->
{#if showAdoption}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mt-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Adoption Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="adoptedDate" class={labelClass}>Adoption Date</label>
<input type="date" id="adoptedDate" name="adoptedDate" value={dateVal(pet.adoptedDate)} class={inputClass} />
</div>
<div>
<label for="adoptionFee" class={labelClass}>Adoption Fee ($)</label>
<input type="text" id="adoptionFee" name="adoptionFee" value={pet.adoptionFee != null ? (pet.adoptionFee / 100).toFixed(2) : ''} class={inputClass} placeholder="0.00" />
</div>
<div class="flex items-center">
<label class="inline-flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="adoptionFeeCollected" checked={pet.adoptionFeeCollected ?? false} class="w-5 h-5 rounded border-gray-200 text-teal-600 focus:ring-teal-600" />
<span class="text-sm font-medium text-gray-700">Fee collected</span>
</label>
</div>
<div>
<label for="adopterName" class={labelClass}>Adopter Name</label>
<input type="text" id="adopterName" name="adopterName" value={pet.adopterName ?? ''} class={inputClass} />
</div>
<div>
<label for="adopterEmail" class={labelClass}>Adopter Email</label>
<input type="email" id="adopterEmail" name="adopterEmail" value={pet.adopterEmail ?? ''} class={inputClass} />
</div>
<div>
<label for="adopterPhone" class={labelClass}>Adopter Phone</label>
<input type="tel" id="adopterPhone" name="adopterPhone" value={pet.adopterPhone ?? ''} class={inputClass} />
</div>
</div>
</div>
{/if}
</fieldset>
{#if canEdit}
<div class="flex justify-end gap-3 mt-6">
<a href="/admin/pets" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-200 text-gray-900 hover:bg-gray-100 transition-colors">
Cancel
</a>
<button
type="submit"
disabled={submitting}
class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
{:else}
<div class="flex justify-end mt-6">
<a href="/admin/pets" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-200 text-gray-900 hover:bg-gray-100 transition-colors">
Back to Pets
</a>
</div>
{/if}
</form>