From 63f127dfca82e6895f786b75538fae1b2fd8e22b Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:24 -0500 Subject: [PATCH] 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) --- src/routes/admin/pets/[id]/+layout.server.ts | 31 ++ src/routes/admin/pets/[id]/+layout.svelte | 63 ++++ src/routes/admin/pets/[id]/+page.server.ts | 9 + src/routes/admin/pets/[id]/+page.svelte | 114 +++++++ .../admin/pets/[id]/fosters/+page.server.ts | 137 +++++++++ .../admin/pets/[id]/fosters/+page.svelte | 223 ++++++++++++++ .../admin/pets/[id]/medical/+page.server.ts | 99 ++++++ .../admin/pets/[id]/medical/+page.svelte | 257 ++++++++++++++++ .../admin/pets/[id]/photos/+page.server.ts | 149 +++++++++ .../admin/pets/[id]/photos/+page.svelte | 209 +++++++++++++ .../admin/pets/[id]/profile/+page.server.ts | 132 ++++++++ .../admin/pets/[id]/profile/+page.svelte | 284 ++++++++++++++++++ 12 files changed, 1707 insertions(+) create mode 100644 src/routes/admin/pets/[id]/+layout.server.ts create mode 100644 src/routes/admin/pets/[id]/+layout.svelte create mode 100644 src/routes/admin/pets/[id]/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/+page.svelte create mode 100644 src/routes/admin/pets/[id]/fosters/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/fosters/+page.svelte create mode 100644 src/routes/admin/pets/[id]/medical/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/medical/+page.svelte create mode 100644 src/routes/admin/pets/[id]/photos/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/photos/+page.svelte create mode 100644 src/routes/admin/pets/[id]/profile/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/profile/+page.svelte diff --git a/src/routes/admin/pets/[id]/+layout.server.ts b/src/routes/admin/pets/[id]/+layout.server.ts new file mode 100644 index 0000000..34e2e94 --- /dev/null +++ b/src/routes/admin/pets/[id]/+layout.server.ts @@ -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 + } + }; +}; diff --git a/src/routes/admin/pets/[id]/+layout.svelte b/src/routes/admin/pets/[id]/+layout.svelte new file mode 100644 index 0000000..14fbdb3 --- /dev/null +++ b/src/routes/admin/pets/[id]/+layout.svelte @@ -0,0 +1,63 @@ + + + + {data.pet.name} — {data.orgConfig.orgInfo.name} Admin + + +
+ +
+ + + + + +
+

{data.pet.name}

+ +
+
+ + + + + {@render children()} +
diff --git a/src/routes/admin/pets/[id]/+page.server.ts b/src/routes/admin/pets/[id]/+page.server.ts new file mode 100644 index 0000000..e14237e --- /dev/null +++ b/src/routes/admin/pets/[id]/+page.server.ts @@ -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 {}; +}; diff --git a/src/routes/admin/pets/[id]/+page.svelte b/src/routes/admin/pets/[id]/+page.svelte new file mode 100644 index 0000000..3e6b29d --- /dev/null +++ b/src/routes/admin/pets/[id]/+page.svelte @@ -0,0 +1,114 @@ + + +
+ +
+
+

Species

+

{pet.species}

+
+
+

Sex

+

{pet.sex}

+
+
+

Breed

+

{pet.breed ?? '—'}

+
+
+

Age

+

{pet.age ?? '—'}

+
+
+ + +
+

Pet Details

+
+
+
Intake Date
+
{formatDate(pet.intakeDate)}
+
+
+
Weight
+
{pet.weight ?? '—'}
+
+
+
Color
+
{pet.color ?? '—'}
+
+
+
Microchip ID
+
{pet.microchipId ?? '—'}
+
+
+
Featured
+
{pet.featured ? 'Yes' : 'No'}
+
+
+
Published
+
{pet.published ? 'Yes' : 'No'}
+
+
+
+ + {#if pet.description} +
+

Description

+

{pet.description}

+
+ {/if} + + +
+

Compatibility & Care

+
+ {#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} +
+ {#if item.value === true} + ✓ + {:else if item.value === false} + ✗ + {:else} + — + {/if} + {item.label} +
+ {/each} +
+
+ + + +
diff --git a/src/routes/admin/pets/[id]/fosters/+page.server.ts b/src/routes/admin/pets/[id]/fosters/+page.server.ts new file mode 100644 index 0000000..2dc8989 --- /dev/null +++ b/src/routes/admin/pets/[id]/fosters/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/pets/[id]/fosters/+page.svelte b/src/routes/admin/pets/[id]/fosters/+page.svelte new file mode 100644 index 0000000..3de8926 --- /dev/null +++ b/src/routes/admin/pets/[id]/fosters/+page.svelte @@ -0,0 +1,223 @@ + + +{#if showSuccess} +
+ + + + Foster assignment saved successfully. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + + +
+

Add Foster Placement

+ +
+
+
+ + + {#if fosterUsers.length === 0} +

No foster-role users found. Add users with the "foster" role in User Management.

+ {/if} +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + + +
+
+
+ + +{#if assignments.length === 0} +
+ + + +

No foster placements yet

+

Add the first placement using the form above.

+
+{:else} +
+ {#each assignments as assignment} +
+
+
+
+

{assignment.fosterName ?? 'Unknown User'}

+ {#if assignment.isCurrent} + + Current + + {/if} + {#if assignment.fosterEmail} + {assignment.fosterEmail} + {/if} +
+ +
+ + + + + {formatDate(assignment.startDate)} + {#if assignment.endDate} + to {formatDate(assignment.endDate)} + {:else} + — ongoing + {/if} + +
+ + {#if assignment.notes} +

{assignment.notes}

+ {/if} +
+ +
+ +
+ + + +
+ + + +
+
+
+ {/each} +
+{/if} + + { + 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; }} +/> diff --git a/src/routes/admin/pets/[id]/medical/+page.server.ts b/src/routes/admin/pets/[id]/medical/+page.server.ts new file mode 100644 index 0000000..72321cf --- /dev/null +++ b/src/routes/admin/pets/[id]/medical/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/pets/[id]/medical/+page.svelte b/src/routes/admin/pets/[id]/medical/+page.svelte new file mode 100644 index 0000000..509ab37 --- /dev/null +++ b/src/routes/admin/pets/[id]/medical/+page.svelte @@ -0,0 +1,257 @@ + + +{#if showSuccess} +
+ + + + Medical record added successfully. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + + +
+

Add Medical Record

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+ + +{#if records.length === 0} +
+ + + +

No medical records yet

+

Add the first record using the form above.

+
+{:else} +
+ {#each [...groupedRecords().entries()] as [dateKey, dateRecords]} +
+

+ {formatDate(dateKey === 'unknown' ? null : dateKey)} +

+
+ {#each dateRecords as record} +
+
+
+ + {formatType(record.type)} + + {record.name} +
+ {#if record.notes} +

{record.notes}

+ {/if} +
+ {#if record.vetName} + + + + + {record.vetName}{record.vetClinic ? ` (${record.vetClinic})` : ''} + + {/if} + {#if record.cost} + + + + + {formatCost(record.cost)} + + {/if} +
+
+ +
+ {/each} +
+
+ {/each} +
+{/if} + + { + 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; }} +/> diff --git a/src/routes/admin/pets/[id]/photos/+page.server.ts b/src/routes/admin/pets/[id]/photos/+page.server.ts new file mode 100644 index 0000000..5c0f7e9 --- /dev/null +++ b/src/routes/admin/pets/[id]/photos/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/pets/[id]/photos/+page.svelte b/src/routes/admin/pets/[id]/photos/+page.svelte new file mode 100644 index 0000000..65eaf03 --- /dev/null +++ b/src/routes/admin/pets/[id]/photos/+page.svelte @@ -0,0 +1,209 @@ + + +{#if showSuccess} +
+ + + + Photo uploaded successfully. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + +{#if canEdit} +
+

Upload Photo

+
+ +
+ +
+ +
+{/if} + + +{#if photos.length === 0} +
+ + + +

No photos yet

+ {#if canEdit}

Upload the first photo using the form above.

{/if} +
+{:else} +
+ {#each photos as photo, index} +
+
+ {photo.alt +
+ + {#if canEdit} + +
+ +
+ + +
+ + + +
+ + +
+
+ + +
+ {#if photo.isPrimary} + Primary + {:else} + #{index + 1} + {/if} +
+ {/if} +
+ {/each} +
+{/if} + +{#if canEdit} + { + 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} diff --git a/src/routes/admin/pets/[id]/profile/+page.server.ts b/src/routes/admin/pets/[id]/profile/+page.server.ts new file mode 100644 index 0000000..e68a410 --- /dev/null +++ b/src/routes/admin/pets/[id]/profile/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/admin/pets/[id]/profile/+page.svelte b/src/routes/admin/pets/[id]/profile/+page.svelte new file mode 100644 index 0000000..381d3d1 --- /dev/null +++ b/src/routes/admin/pets/[id]/profile/+page.svelte @@ -0,0 +1,284 @@ + + +{#if showSuccess} +
+ + + + Pet updated successfully. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + +
+
+ +
+

Basic Information

+
+
+ + +
+ +
+ +
+ {#each ['dog', 'cat', 'other'] as s} + + {/each} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Compatibility & Care

+
+ {#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} + + {/each} +
+
+ + +
+
+ + +
+

Notes

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Intake Information

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Settings

+
+
+ + +
+ +
+ + +
+
+
+ + + {#if showAdoption} +
+

Adoption Information

+
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ {/if} +
+ + {#if canEdit} +
+ + Cancel + + +
+ {:else} + + {/if} +