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