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>
|
||||
Reference in New Issue
Block a user