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) <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let selectedSpecies = $state('dog');
|
||||||
|
let selectedSex = $state('unknown');
|
||||||
|
let photoFiles = $state<File[]>([]);
|
||||||
|
let photoPreviews = $state<string[]>([]);
|
||||||
|
|
||||||
|
let cameraInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let galleryInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function handlePhotos(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (!input.files) return;
|
||||||
|
for (const file of Array.from(input.files)) {
|
||||||
|
if (!file.type.startsWith('image/')) continue;
|
||||||
|
photoFiles = [...photoFiles, file];
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
photoPreviews = [...photoPreviews, url];
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(index: number) {
|
||||||
|
URL.revokeObjectURL(photoPreviews[index]);
|
||||||
|
photoFiles = photoFiles.filter((_, i) => i !== index);
|
||||||
|
photoPreviews = photoPreviews.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Quick Intake — {data.orgConfig.orgInfo.name} Admin</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-lg mx-auto pb-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<a href="/admin/pets" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
|
||||||
|
<svg class="w-6 h-6" 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>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Quick Intake</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
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 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Quick ID</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="w-full px-4 py-3 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-base"
|
||||||
|
placeholder="Pet name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Species -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Species</label>
|
||||||
|
<input type="hidden" name="species" value={selectedSpecies} />
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each [{ value: 'dog', label: 'Dog', icon: '🐕' }, { value: 'cat', label: 'Cat', icon: '🐈' }, { value: 'other', label: 'Other', icon: '🐾' }] as s}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedSpecies = s.value}
|
||||||
|
class="flex items-center justify-center gap-2 py-3 rounded-lg border-2 font-medium text-sm transition-all {selectedSpecies === s.value ? 'border-teal-600 bg-teal-50 text-teal-600' : 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'}"
|
||||||
|
>
|
||||||
|
<span class="text-lg">{s.icon}</span> {s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sex -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Sex</label>
|
||||||
|
<input type="hidden" name="sex" value={selectedSex} />
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each [{ value: 'male', label: 'Male' }, { value: 'female', label: 'Female' }, { value: 'unknown', label: 'Unknown' }] as s}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedSex = s.value}
|
||||||
|
class="flex items-center justify-center py-3 rounded-lg border-2 font-medium text-sm transition-all {selectedSex === s.value ? 'border-teal-600 bg-teal-50 text-teal-600' : 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'}"
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Details</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="breed" class="block text-sm font-medium text-gray-700 mb-1">Breed</label>
|
||||||
|
<input type="text" id="breed" name="breed" class="w-full px-4 py-3 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-base" placeholder="e.g., Labrador Mix" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="age" class="block text-sm font-medium text-gray-700 mb-1">Age Estimate</label>
|
||||||
|
<input type="text" id="age" name="age" class="w-full px-4 py-3 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-base" placeholder="e.g., 2 years, puppy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Photos</h2>
|
||||||
|
|
||||||
|
<input bind:this={cameraInput} type="file" accept="image/*" capture="environment" onchange={handlePhotos} class="hidden" />
|
||||||
|
<input bind:this={galleryInput} type="file" accept="image/*" multiple onchange={handlePhotos} class="hidden" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => cameraInput?.click()}
|
||||||
|
class="flex items-center justify-center gap-2 py-4 rounded-lg border-2 border-dashed border-teal-600 bg-teal-50 text-teal-600 font-medium text-sm transition-all hover:bg-teal-100 active:bg-teal-100"
|
||||||
|
>
|
||||||
|
<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 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Take Photo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => galleryInput?.click()}
|
||||||
|
class="flex items-center justify-center gap-2 py-4 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 text-gray-600 font-medium text-sm transition-all hover:bg-gray-100 active:bg-gray-100"
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
Gallery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if photoPreviews.length > 0}
|
||||||
|
<div class="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1">
|
||||||
|
{#each photoPreviews as preview, i}
|
||||||
|
<div class="relative flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 {i === 0 ? 'border-teal-600' : 'border-gray-200'}">
|
||||||
|
<img src={preview} alt="Photo {i + 1}" class="w-full h-full object-cover" />
|
||||||
|
{#if i === 0}
|
||||||
|
<div class="absolute top-1 left-1 bg-teal-600 text-white rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removePhoto(i)}
|
||||||
|
class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-black/80 cursor-pointer"
|
||||||
|
aria-label="Remove photo"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-2">First photo is the primary photo</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-400 text-center py-4">No photos yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intake Info -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Intake Info</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="intakeSourceId" class="block text-sm font-medium text-gray-700 mb-1">Intake Source</label>
|
||||||
|
<select id="intakeSourceId" name="intakeSourceId" class="w-full px-4 py-3 rounded-lg border border-gray-200 bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 text-base">
|
||||||
|
<option value="">Select source</option>
|
||||||
|
{#each data.intakeSources as source}
|
||||||
|
<option value={source.id}>{source.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Notes</h2>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-4 py-3 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-base resize-y"
|
||||||
|
placeholder="Any initial notes about this pet..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="w-full py-4 text-base font-semibold rounded-xl 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 shadow-sm"
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<span class="inline-flex items-center gap-2 justify-center">
|
||||||
|
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Create Pet Profile
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -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<string, ReturnType<typeof desc>> = {
|
||||||
|
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<string | null>`(
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||||
|
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const canEdit = $derived(data.canEditPets);
|
||||||
|
|
||||||
|
let deleteTarget: { id: number; name: string } | null = $state(null);
|
||||||
|
let confirmOpen = $derived(deleteTarget !== null);
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
let searchValue = $state(data.q);
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold',
|
||||||
|
'pending', 'trial', 'hospice', 'nursing', 'whelping',
|
||||||
|
'deceased', 'transferred'
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'newest', label: 'Newest First' },
|
||||||
|
{ value: 'oldest', label: 'Oldest First' },
|
||||||
|
{ value: 'name_asc', label: 'Name A→Z' },
|
||||||
|
{ value: 'name_desc', label: 'Name Z→A' },
|
||||||
|
{ value: 'intake_newest', label: 'Intake (Recent)' },
|
||||||
|
{ value: 'intake_oldest', label: 'Intake (Oldest)' },
|
||||||
|
{ value: 'status', label: 'Status' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleFilterChange(param: string, value: string) {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (value) {
|
||||||
|
url.searchParams.set(param, value);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(param);
|
||||||
|
}
|
||||||
|
url.searchParams.delete('page');
|
||||||
|
goto(url.pathname + url.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchInput(value: string) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (value) {
|
||||||
|
url.searchParams.set('q', value);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('q');
|
||||||
|
}
|
||||||
|
url.searchParams.delete('page');
|
||||||
|
goto(url.pathname + url.search, { noScroll: true, keepFocus: true });
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseUrl(): string {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
return url.pathname + '?' + url.searchParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(col: string) {
|
||||||
|
const map: Record<string, [string, string]> = {
|
||||||
|
name: ['name_asc', 'name_desc'],
|
||||||
|
intake: ['intake_newest', 'intake_oldest'],
|
||||||
|
created: ['newest', 'oldest'],
|
||||||
|
status: ['status', 'status']
|
||||||
|
};
|
||||||
|
const [asc, desc] = map[col] ?? [col + '_asc', col + '_desc'];
|
||||||
|
const next = data.sort === asc ? desc : asc;
|
||||||
|
handleFilterChange('sort', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortIndicator(col: string): string {
|
||||||
|
const map: Record<string, string[]> = {
|
||||||
|
name: ['name_asc', 'name_desc'],
|
||||||
|
intake: ['intake_newest', 'intake_oldest'],
|
||||||
|
created: ['newest', 'oldest'],
|
||||||
|
status: ['status']
|
||||||
|
};
|
||||||
|
const vals = map[col] ?? [];
|
||||||
|
if (vals[0] === data.sort) return ' ↑';
|
||||||
|
if (vals[1] === data.sort) return ' ↓';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleteFormEl: HTMLFormElement | undefined = $state();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pets — {data.orgConfig.orgInfo.name} Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Pets</h1>
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a
|
||||||
|
href="/admin/intake"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Quick Intake
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/pets/new"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
<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="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Pet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Filters -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-3">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchValue}
|
||||||
|
oninput={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 transition"
|
||||||
|
value={data.status}
|
||||||
|
onchange={(e) => handleFilterChange('status', e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
{#each statuses as s}
|
||||||
|
<option value={s}>{s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 transition"
|
||||||
|
value={data.species}
|
||||||
|
onchange={(e) => handleFilterChange('species', e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Species</option>
|
||||||
|
<option value="dog">Dog</option>
|
||||||
|
<option value="cat">Cat</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:border-teal-600 transition"
|
||||||
|
value={data.sort}
|
||||||
|
onchange={(e) => handleFilterChange('sort', e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{#each sortOptions as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results count -->
|
||||||
|
<p class="text-sm text-gray-500 mb-3">
|
||||||
|
{data.total} pet{data.total !== 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-100 bg-gray-50/50">
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[22%]">
|
||||||
|
<button onclick={() => toggleSort('name')} class="hover:text-teal-600 cursor-pointer">Name{sortIndicator('name')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[7%]">Species</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[16%]">Breed</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[11%]">
|
||||||
|
<button onclick={() => toggleSort('status')} class="hover:text-teal-600 cursor-pointer">Status{sortIndicator('status')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[12%]">
|
||||||
|
<button onclick={() => toggleSort('intake')} class="hover:text-teal-600 cursor-pointer">Intake{sortIndicator('intake')}</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[15%]">Vet</th>
|
||||||
|
<th class="px-4 py-3 text-left font-semibold text-gray-700 w-[12%]">Foster</th>
|
||||||
|
{#if canEdit}<th class="px-4 py-3 w-[5%]"></th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{#each data.pets as pet}
|
||||||
|
<tr class="hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 truncate" title={pet.name}>
|
||||||
|
<a href="/admin/pets/{pet.id}" class="font-medium text-teal-600 hover:text-teal-800 hover:underline">
|
||||||
|
{pet.name}
|
||||||
|
</a>
|
||||||
|
{#if pet.featured}
|
||||||
|
<span class="ml-1 text-xs text-amber-500" title="Featured">★</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 capitalize text-gray-600">{pet.species}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 truncate" title={pet.breed ?? ''}>{pet.breed ?? '—'}</td>
|
||||||
|
<td class="px-4 py-3"><StatusBadge status={pet.status} /></td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 whitespace-nowrap">{formatDate(pet.intakeDate)}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 truncate" title={pet.vetName ?? ''}>{pet.vetName ?? '—'}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 truncate" title={pet.currentFoster ?? ''}>{pet.currentFoster ?? '—'}</td>
|
||||||
|
{#if canEdit}
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onclick={() => deleteTarget = { id: pet.id, name: pet.name }}
|
||||||
|
class="text-red-500 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan={canEdit ? 8 : 7} class="px-4 py-12 text-center text-gray-400">
|
||||||
|
No pets found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination page={data.page} totalPages={data.totalPages} baseUrl={buildBaseUrl()} />
|
||||||
|
|
||||||
|
{#if canEdit}
|
||||||
|
<!-- Hidden delete form -->
|
||||||
|
<form
|
||||||
|
bind:this={deleteFormEl}
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
deleteTarget = null;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={deleteTarget?.id ?? ''} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmOpen}
|
||||||
|
title="Delete Pet"
|
||||||
|
message="Are you sure you want to delete {deleteTarget?.name ?? 'this pet'}? This action cannot be undone and will remove all associated records."
|
||||||
|
confirmText="Delete"
|
||||||
|
onconfirm={() => deleteFormEl?.requestSubmit()}
|
||||||
|
oncancel={() => deleteTarget = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
'available', 'foster', 'medical_hold', 'behavioral_hold',
|
||||||
|
'pending', 'trial', 'hospice', 'nursing', 'whelping'
|
||||||
|
];
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// Photo upload state
|
||||||
|
let photoFiles = $state<File[]>([]);
|
||||||
|
let photoPreviews = $state<string[]>([]);
|
||||||
|
let photoDragover = $state(false);
|
||||||
|
let photoInputEl: HTMLInputElement;
|
||||||
|
|
||||||
|
function addPhotoFiles(fileList: FileList | null) {
|
||||||
|
if (!fileList) return;
|
||||||
|
const newFiles = Array.from(fileList).filter((f) => f.type.startsWith('image/'));
|
||||||
|
photoFiles = [...photoFiles, ...newFiles];
|
||||||
|
regeneratePreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
function regeneratePreviews() {
|
||||||
|
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
photoPreviews = photoFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(index: number) {
|
||||||
|
if (photoPreviews[index]) URL.revokeObjectURL(photoPreviews[index]);
|
||||||
|
photoFiles = photoFiles.filter((_, i) => i !== index);
|
||||||
|
photoPreviews = photoPreviews.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhotoDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
photoDragover = false;
|
||||||
|
addPhotoFiles(e.dataTransfer?.files ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePhotoInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
addPhotoFiles(input.files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Add Pet — {data.orgConfig.orgInfo.name} Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-4xl">
|
||||||
|
<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>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Add New Pet</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
submitting = true;
|
||||||
|
for (const file of photoFiles) {
|
||||||
|
formData.append('photos', file);
|
||||||
|
}
|
||||||
|
return async ({ update }) => {
|
||||||
|
submitting = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<!-- 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={form?.values?.name ?? ''}
|
||||||
|
class={inputClass}
|
||||||
|
placeholder="Pet name"
|
||||||
|
/>
|
||||||
|
</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={form?.values?.species === s || (!form?.values?.species && s === 'dog')}
|
||||||
|
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={form?.values?.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">Unknown</option>
|
||||||
|
<option value="male" selected={form?.values?.sex === 'male'}>Male</option>
|
||||||
|
<option value="female" selected={form?.values?.sex === 'female'}>Female</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="age" class={labelClass}>Age Estimate</label>
|
||||||
|
<input type="text" id="age" name="age" value={form?.values?.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={form?.values?.birthday ?? ''} class={inputClass} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="weight" class={labelClass}>Weight</label>
|
||||||
|
<input type="text" id="weight" name="weight" value={form?.values?.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={form?.values?.color ?? ''} class={inputClass} placeholder="e.g., Black and white" />
|
||||||
|
</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" placeholder="Tell us about this pet...">{form?.values?.description ?? ''}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compatibility -->
|
||||||
|
<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">Compatibility & Care</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{#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}
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name={item.name} 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={form?.values?.microchipId ?? ''} class="{inputClass} max-w-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intake Information -->
|
||||||
|
<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">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={form?.values?.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={form?.values?.intakeSourceId == String(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={form?.values?.vetId == String(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">
|
||||||
|
<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 statusOptions as s}
|
||||||
|
<option value={s} selected={s === (form?.values?.status ?? 'available')}>
|
||||||
|
{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={form?.values?.featured === 'on'} 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={form?.values?.published !== 'off'} 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>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<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">Photos</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={photoInputEl}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onchange={handlePhotoInputChange}
|
||||||
|
class="sr-only"
|
||||||
|
id="photo-upload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
onclick={() => 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(); } }}
|
||||||
|
>
|
||||||
|
<svg class="mx-auto h-10 w-10 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
Drop photos here or <span class="text-teal-600 underline">browse</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">JPG, PNG, WebP, GIF up to 10MB each</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if photoFiles.length > 0}
|
||||||
|
<div class="mt-4 flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{#each photoFiles as file, i}
|
||||||
|
<div class="relative flex-shrink-0 group">
|
||||||
|
<img src={photoPreviews[i]} alt="Preview of {file.name}" class="h-24 w-24 rounded-lg object-cover border border-gray-200" />
|
||||||
|
{#if i === 0}
|
||||||
|
<span class="absolute top-1 left-1 px-1.5 py-0.5 text-[10px] font-semibold rounded bg-teal-600 text-white">Primary</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removePhoto(i)}
|
||||||
|
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer shadow-sm"
|
||||||
|
aria-label="Remove {file.name}"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" 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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400">{photoFiles.length} photo{photoFiles.length === 1 ? '' : 's'} selected. The first photo will be the primary image.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<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 ? 'Creating...' : 'Create Pet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user