From 55c1eb7a9fce686a1d4d7884371102690993f9a3 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:15 -0500 Subject: [PATCH] Add admin pet list, new pet form, and quick intake (Task 32a) Pet list: search by name/breed/microchip, filter by status/species, sort columns, pagination, delete with confirm modal. New pet form: full fields including photos with drag-and-drop preview. Quick intake: mobile-optimized form with camera/gallery photo capture, toggles for species/sex, sets intake date to today. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/intake/+page.server.ts | 89 ++++++ src/routes/admin/intake/+page.svelte | 252 +++++++++++++++++ src/routes/admin/pets/+page.server.ts | 109 ++++++++ src/routes/admin/pets/+page.svelte | 270 ++++++++++++++++++ src/routes/admin/pets/new/+page.server.ts | 129 +++++++++ src/routes/admin/pets/new/+page.svelte | 326 ++++++++++++++++++++++ 6 files changed, 1175 insertions(+) create mode 100644 src/routes/admin/intake/+page.server.ts create mode 100644 src/routes/admin/intake/+page.svelte create mode 100644 src/routes/admin/pets/+page.server.ts create mode 100644 src/routes/admin/pets/+page.svelte create mode 100644 src/routes/admin/pets/new/+page.server.ts create mode 100644 src/routes/admin/pets/new/+page.svelte diff --git a/src/routes/admin/intake/+page.server.ts b/src/routes/admin/intake/+page.server.ts new file mode 100644 index 0000000..60afd85 --- /dev/null +++ b/src/routes/admin/intake/+page.server.ts @@ -0,0 +1,89 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos, intakeSources } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/slugify'; +import { hasPermission } from '$lib/roles'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const sources = await db.select().from(intakeSources).where(eq(intakeSources.active, true)); + + return { intakeSources: sources }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + 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' }); + + // Generate unique slug + let slug = slugify(name); + const existing = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, slug)); + if (existing.length > 0) slug = `${slug}-${Date.now()}`; + + const breed = fd.get('breed')?.toString().trim() || null; + const sex = fd.get('sex')?.toString() || 'unknown'; + const age = fd.get('age')?.toString().trim() || null; + const intakeSourceId = fd.get('intakeSourceId')?.toString() + ? parseInt(fd.get('intakeSourceId') as string, 10) + : null; + const notes = fd.get('notes')?.toString().trim() || null; + const intakeDate = new Date(); + + const [result] = await db.insert(pets).values({ + name, + species: species as 'dog' | 'cat' | 'other', + breed, + sex: sex as 'male' | 'female' | 'unknown', + age, + description: notes, + slug, + intakeDate, + intakeSourceId, + status: 'available', + featured: false, + published: true + }); + + const petId = result.insertId; + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet', + entityId: Number(petId), + diff: { name, species, source: 'intake' } + }); + + // Handle photo uploads + const photoFiles = fd.getAll('photos'); + let photoIndex = 0; + for (const file of photoFiles) { + if (!(file instanceof File) || file.size === 0) continue; + try { + const { url } = await saveUpload(file, 'pets'); + await db.insert(petPhotos).values({ + petId: Number(petId), + url, + isPrimary: photoIndex === 0, + sortOrder: photoIndex + }); + photoIndex++; + } catch { + // Skip invalid files silently + } + } + + redirect(303, `/admin/pets/${petId}`); + } +}; diff --git a/src/routes/admin/intake/+page.svelte b/src/routes/admin/intake/+page.svelte new file mode 100644 index 0000000..94011b4 --- /dev/null +++ b/src/routes/admin/intake/+page.svelte @@ -0,0 +1,252 @@ + + + + Quick Intake — {data.orgConfig.orgInfo.name} Admin + + + +
+ +
+ + + + + +

Quick Intake

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
{ + submitting = true; + formData.delete('photos'); + for (const file of photoFiles) { + formData.append('photos', file); + } + return async ({ update }) => { + submitting = false; + await update(); + }; + }} + class="space-y-4" + > + +
+

Quick ID

+ +
+
+ + +
+ + +
+ + +
+ {#each [{ value: 'dog', label: 'Dog', icon: 'šŸ•' }, { value: 'cat', label: 'Cat', icon: '🐈' }, { value: 'other', label: 'Other', icon: '🐾' }] as s} + + {/each} +
+
+ + +
+ + +
+ {#each [{ value: 'male', label: 'Male' }, { value: 'female', label: 'Female' }, { value: 'unknown', label: 'Unknown' }] as s} + + {/each} +
+
+
+
+ + +
+

Details

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

Photos

+ + + + +
+ + +
+ + {#if photoPreviews.length > 0} +
+ {#each photoPreviews as preview, i} +
+ Photo {i + 1} + {#if i === 0} +
+ + + +
+ {/if} + +
+ {/each} +
+

First photo is the primary photo

+ {:else} +

No photos yet.

+ {/if} +
+ + +
+

Intake Info

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

Notes

+ +
+ + + +
+
diff --git a/src/routes/admin/pets/+page.server.ts b/src/routes/admin/pets/+page.server.ts new file mode 100644 index 0000000..4d6624a --- /dev/null +++ b/src/routes/admin/pets/+page.server.ts @@ -0,0 +1,109 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, fosterAssignments, vets } from '$lib/server/schema'; +import { eq, like, and, or, sql, count, desc, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +const PER_PAGE = 50; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + + const q = url.searchParams.get('q')?.trim() ?? ''; + const status = url.searchParams.get('status') ?? ''; + const species = url.searchParams.get('species') ?? ''; + const sort = url.searchParams.get('sort') ?? 'intake_newest'; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = []; + if (q) { + conditions.push(or( + like(pets.name, `%${q}%`), + like(pets.breed, `%${q}%`), + like(pets.microchipId, `%${q}%`) + )); + } + if (status) { + conditions.push(eq(pets.status, status as typeof pets.status.enumValues[number])); + } else { + conditions.push(sql`${pets.status} NOT IN ('deceased', 'transferred')`); + } + if (species) { + conditions.push(eq(pets.species, species as typeof pets.species.enumValues[number])); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const sortMap: Record> = { + newest: desc(pets.createdAt), + oldest: asc(pets.createdAt), + name_asc: asc(pets.name), + name_desc: desc(pets.name), + intake_newest: desc(pets.intakeDate), + intake_oldest: asc(pets.intakeDate), + status: asc(pets.status) + }; + const orderBy = sortMap[sort] ?? desc(pets.createdAt); + + const [totalRow, petRows] = await Promise.all([ + db.select({ total: count() }).from(pets).where(where).then((r) => r[0]), + + db.select({ + id: pets.id, + name: pets.name, + species: pets.species, + breed: pets.breed, + status: pets.status, + intakeDate: pets.intakeDate, + featured: pets.featured, + createdAt: pets.createdAt, + vetName: vets.name, + currentFoster: sql`( + SELECT u.name + FROM ${fosterAssignments} fa + JOIN users u ON fa.user_id = u.id + WHERE fa.pet_id = ${pets.id} + AND fa.is_current = true + LIMIT 1 + )`.as('current_foster') + }) + .from(pets) + .leftJoin(vets, eq(pets.vetId, vets.id)) + .where(where) + .orderBy(orderBy) + .limit(PER_PAGE) + .offset(offset) + ]); + + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return { + pets: petRows, + total, + page, + totalPages, + q, + status, + species, + sort + }; +}; + +export const actions: Actions = { + delete: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const formData = await request.formData(); + const id = parseInt(formData.get('id') as string, 10); + + if (!id || isNaN(id)) { + return fail(400, { error: 'Invalid pet ID' }); + } + + await db.delete(pets).where(eq(pets.id, id)); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/+page.svelte b/src/routes/admin/pets/+page.svelte new file mode 100644 index 0000000..d5b2ba6 --- /dev/null +++ b/src/routes/admin/pets/+page.svelte @@ -0,0 +1,270 @@ + + + + Pets — {data.orgConfig.orgInfo.name} Admin + + +
+

Pets

+ {#if canEdit} + + {/if} +
+ + +
+
+
+ + + + handleSearchInput(e.currentTarget.value)} + placeholder="Search by name, breed, or microchip..." + class="w-full pl-9 pr-4 py-2.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-600/40 focus:border-teal-600 transition" + /> +
+
+ + + +
+
+
+ + +

+ {data.total} pet{data.total !== 1 ? 's' : ''} found +

+ + +
+
+ + + + + + + + + + + {#if canEdit}{/if} + + + + {#each data.pets as pet} + + + + + + + + + {#if canEdit} + + {/if} + + {:else} + + + + {/each} + +
+ + SpeciesBreed + + + + VetFoster
+ + {pet.name} + + {#if pet.featured} + ā˜… + {/if} + {pet.species}{pet.breed ?? '—'}{formatDate(pet.intakeDate)}{pet.vetName ?? '—'}{pet.currentFoster ?? '—'} + +
+ No pets found. +
+
+
+ + + + +{#if canEdit} + +
{ + return async ({ update }) => { + deleteTarget = null; + await update(); + }; + }} + class="hidden" + > + +
+ + deleteFormEl?.requestSubmit()} + oncancel={() => deleteTarget = null} + /> +{/if} diff --git a/src/routes/admin/pets/new/+page.server.ts b/src/routes/admin/pets/new/+page.server.ts new file mode 100644 index 0000000..bc1d6a3 --- /dev/null +++ b/src/routes/admin/pets/new/+page.server.ts @@ -0,0 +1,129 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos, intakeSources, vets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/slugify'; +import { hasPermission } from '$lib/roles'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + 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, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + 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', values: Object.fromEntries(fd) }); + } + if (!['dog', 'cat', 'other'].includes(species)) { + return fail(400, { error: 'Invalid species', values: Object.fromEntries(fd) }); + } + + // Generate unique slug + let slug = slugify(name); + const existing = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, slug)); + if (existing.length > 0) { + slug = `${slug}-${Date.now()}`; + } + + 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 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') !== 'off'; + const goodWithDogs = fd.get('goodWithDogs') === 'on' ? true : fd.get('goodWithDogs') === 'off' ? false : null; + const goodWithCats = fd.get('goodWithCats') === 'on' ? true : fd.get('goodWithCats') === 'off' ? false : null; + const goodWithKids = fd.get('goodWithKids') === 'on' ? true : fd.get('goodWithKids') === 'off' ? false : 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 [result] = await db.insert(pets).values({ + name, + species: species as 'dog' | 'cat' | 'other', + breed, + sex: sex as 'male' | 'female' | 'unknown', + age, + birthday, + weight, + color, + description, + 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 + }); + + const newPetId = result.insertId; + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet', + entityId: Number(newPetId), + diff: { name, species, status } + }); + + // Handle photo uploads + const photoEntries = fd.getAll('photos').filter( + (entry): entry is File => entry instanceof File && entry.size > 0 + ); + + for (let i = 0; i < photoEntries.length; i++) { + try { + const { url } = await saveUpload(photoEntries[i], 'pets'); + await db.insert(petPhotos).values({ + petId: Number(newPetId), + url, + isPrimary: i === 0, + sortOrder: i + }); + } catch { + // Skip invalid files — pet is already created + } + } + + redirect(303, `/admin/pets/${newPetId}`); + } +}; diff --git a/src/routes/admin/pets/new/+page.svelte b/src/routes/admin/pets/new/+page.svelte new file mode 100644 index 0000000..96c646f --- /dev/null +++ b/src/routes/admin/pets/new/+page.svelte @@ -0,0 +1,326 @@ + + + + Add Pet — {data.orgConfig.orgInfo.name} Admin + + +
+
+ + + + + +

Add New Pet

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
{ + submitting = true; + for (const file of photoFiles) { + formData.append('photos', file); + } + return async ({ update }) => { + submitting = false; + await update(); + }; + }} + class="space-y-6" + > + +
+

Basic Information

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

Compatibility & Care

+
+ {#each [ + { name: 'goodWithDogs', label: 'Good with dogs' }, + { name: 'goodWithCats', label: 'Good with cats' }, + { name: 'goodWithKids', label: 'Good with kids' }, + { name: 'houseTrained', label: 'House trained' }, + { name: 'spayedNeutered', label: 'Spayed/Neutered' }, + { name: 'microchipped', label: 'Microchipped' } + ] as item} + + {/each} +
+
+ + +
+
+ + +
+

Intake Information

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

Settings

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

Photos

+ + + + +
photoInputEl.click()} + ondrop={handlePhotoDrop} + ondragover={(e) => { e.preventDefault(); photoDragover = true; }} + ondragleave={() => photoDragover = false} + class="relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors + {photoDragover ? 'border-teal-600 bg-teal-50' : 'border-gray-200 bg-white hover:border-teal-600 hover:bg-teal-50/50'}" + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); photoInputEl.click(); } }} + > + + + +

+ Drop photos here or browse +

+

JPG, PNG, WebP, GIF up to 10MB each

+
+ + {#if photoFiles.length > 0} +
+ {#each photoFiles as file, i} +
+ Preview of {file.name} + {#if i === 0} + Primary + {/if} + +
+ {/each} +
+

{photoFiles.length} photo{photoFiles.length === 1 ? '' : 's'} selected. The first photo will be the primary image.

+ {/if} +
+ + +
+ + Cancel + + +
+
+