Add foster portal with dashboard, submit update, and request supplies
Fosters can view their current animals (with photos), submit updates (note/photo/update types with optional photo upload), and request supplies with urgency levels. Staff is notified via email on each submission. Layout uses orgConfig branding and CSS custom properties. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { fosterAssignments, pets, petPhotos } from '$lib/server/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locals.user.role !== 'foster') {
|
||||||
|
throw redirect(303, locals.user.role === 'sysadmin' || locals.user.role === 'director' ? '/admin' : '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = locals.user.id;
|
||||||
|
|
||||||
|
// Load current foster assignments with pet details for sidebar/context
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: fosterAssignments.id,
|
||||||
|
petId: pets.id,
|
||||||
|
petName: pets.name,
|
||||||
|
petSpecies: pets.species,
|
||||||
|
petBreed: pets.breed,
|
||||||
|
petAge: pets.age,
|
||||||
|
petStatus: pets.status,
|
||||||
|
photoUrl: petPhotos.url
|
||||||
|
})
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.innerJoin(pets, eq(fosterAssignments.petId, pets.id))
|
||||||
|
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
|
||||||
|
.where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: locals.user,
|
||||||
|
orgConfig: locals.orgConfig,
|
||||||
|
assignments
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
let sidebarOpen = $state(false);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/foster', label: 'Dashboard' },
|
||||||
|
{ href: '/foster/submit', label: 'Submit Update' },
|
||||||
|
{ href: '/foster/supplies', label: 'Request Supplies' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const orgName = data.orgConfig?.orgInfo?.name ?? 'FosterFlow';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
{@html `<style>:root{--color-primary:${data.orgConfig.branding.primaryColor};--color-primary-light:${data.orgConfig.branding.primaryColorLight};--color-primary-dark:${data.orgConfig.branding.primaryColorDark};--color-accent:${data.orgConfig.branding.accentColor}}</style>`}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Mobile header -->
|
||||||
|
<div class="lg:hidden flex items-center justify-between bg-white shadow px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if data.orgConfig.branding.logoUrl}
|
||||||
|
<img src={data.orgConfig.branding.logoUrl} alt={orgName} class="h-8 w-auto" />
|
||||||
|
{/if}
|
||||||
|
<h1 class="font-bold text-lg" style="color: var(--color-primary)">{orgName} Foster Portal</h1>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => sidebarOpen = !sidebarOpen} class="p-2 text-gray-600">
|
||||||
|
<span class="text-2xl">{sidebarOpen ? '✕' : '☰'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="{sidebarOpen ? 'block' : 'hidden'} lg:block fixed lg:sticky top-0 left-0 z-40 w-64 h-screen bg-white shadow-lg overflow-y-auto flex flex-col">
|
||||||
|
<div class="p-4 border-b">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
{#if data.orgConfig.branding.logoUrl}
|
||||||
|
<img src={data.orgConfig.branding.logoUrl} alt={orgName} class="h-8 w-auto" />
|
||||||
|
{/if}
|
||||||
|
<h1 class="font-bold text-base" style="color: var(--color-primary)">{orgName}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 font-medium uppercase tracking-wide">Foster Portal</p>
|
||||||
|
<p class="text-sm text-gray-700 mt-1">{data.user.name}</p>
|
||||||
|
{#if data.assignments.length > 0}
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{data.assignments.length} pet{data.assignments.length !== 1 ? 's' : ''} in your care</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="p-2 flex-1">
|
||||||
|
{#each navItems as item}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-700 hover:bg-opacity-10 hover:text-teal-700 transition-colors text-sm font-medium"
|
||||||
|
style="hover:background-color: var(--color-primary)"
|
||||||
|
onclick={() => sidebarOpen = false}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-2 border-t">
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile backdrop -->
|
||||||
|
{#if sidebarOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/30 z-30 lg:hidden"
|
||||||
|
onclick={() => sidebarOpen = false}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={() => {}}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 p-4 lg:p-8 min-w-0">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { fosterAssignments, fosterSubmissions, supplyRequests, pets, petPhotos } from '$lib/server/schema';
|
||||||
|
import { eq, and, desc, count } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
|
|
||||||
|
// Load current foster assignments with pet info and primary photo
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
assignmentId: fosterAssignments.id,
|
||||||
|
startDate: fosterAssignments.startDate,
|
||||||
|
notes: fosterAssignments.notes,
|
||||||
|
petId: pets.id,
|
||||||
|
petName: pets.name,
|
||||||
|
petBreed: pets.breed,
|
||||||
|
petAge: pets.age,
|
||||||
|
petStatus: pets.status,
|
||||||
|
petSpecies: pets.species,
|
||||||
|
photoUrl: petPhotos.url
|
||||||
|
})
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.innerJoin(pets, eq(fosterAssignments.petId, pets.id))
|
||||||
|
.leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true)))
|
||||||
|
.where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true)));
|
||||||
|
|
||||||
|
// Recent submissions (last 10)
|
||||||
|
const recentSubmissions = await db
|
||||||
|
.select({
|
||||||
|
id: fosterSubmissions.id,
|
||||||
|
type: fosterSubmissions.type,
|
||||||
|
content: fosterSubmissions.content,
|
||||||
|
status: fosterSubmissions.status,
|
||||||
|
createdAt: fosterSubmissions.createdAt,
|
||||||
|
petName: pets.name
|
||||||
|
})
|
||||||
|
.from(fosterSubmissions)
|
||||||
|
.innerJoin(pets, eq(fosterSubmissions.petId, pets.id))
|
||||||
|
.where(eq(fosterSubmissions.userId, userId))
|
||||||
|
.orderBy(desc(fosterSubmissions.createdAt))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
// Pending supply requests
|
||||||
|
const pendingRequests = await db
|
||||||
|
.select({
|
||||||
|
id: supplyRequests.id,
|
||||||
|
items: supplyRequests.items,
|
||||||
|
urgency: supplyRequests.urgency,
|
||||||
|
status: supplyRequests.status,
|
||||||
|
createdAt: supplyRequests.createdAt
|
||||||
|
})
|
||||||
|
.from(supplyRequests)
|
||||||
|
.where(and(eq(supplyRequests.userId, userId), eq(supplyRequests.status, 'pending')))
|
||||||
|
.orderBy(desc(supplyRequests.createdAt));
|
||||||
|
|
||||||
|
// Count pending submissions
|
||||||
|
const [pendingSubRow] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(fosterSubmissions)
|
||||||
|
.where(and(eq(fosterSubmissions.userId, userId), eq(fosterSubmissions.status, 'pending')));
|
||||||
|
const pendingSubmissionCount = pendingSubRow?.total ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignments,
|
||||||
|
recentSubmissions,
|
||||||
|
pendingRequests,
|
||||||
|
pendingSubmissionCount
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
function formatDate(d: string | Date): string {
|
||||||
|
return new Date(d).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'text-yellow-700 bg-yellow-100';
|
||||||
|
case 'approved': return 'text-green-700 bg-green-100';
|
||||||
|
case 'rejected': return 'text-red-700 bg-red-100';
|
||||||
|
default: return 'text-gray-700 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'note': return 'Note';
|
||||||
|
case 'photo': return 'Photo';
|
||||||
|
case 'update': return 'Update';
|
||||||
|
default: return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgName = data.orgConfig?.orgInfo?.name ?? 'FosterFlow';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Foster Dashboard — {orgName}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-1">Welcome, {data.user.name}</h1>
|
||||||
|
<p class="text-gray-500 mb-6">Here is an overview of your foster animals and activity.</p>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<p class="text-sm text-gray-500">Active Fosters</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1">{data.assignments.length}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<p class="text-sm text-gray-500">Pending Submissions</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1">{data.pendingSubmissionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<p class="text-sm text-gray-500">Pending Supply Requests</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 mt-1">{data.pendingRequests.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Foster Animals -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">My Animals</h2>
|
||||||
|
{#if data.assignments.length > 0}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a
|
||||||
|
href="/foster/submit"
|
||||||
|
class="text-sm px-4 py-2 rounded-lg font-medium text-white transition-colors"
|
||||||
|
style="background-color: var(--color-primary)"
|
||||||
|
>
|
||||||
|
Submit Update
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/foster/supplies"
|
||||||
|
class="text-sm px-4 py-2 rounded-lg font-medium border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Request Supplies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.assignments.length === 0}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-10 text-center text-gray-400 mb-8">
|
||||||
|
<p class="text-lg mb-2">No animals currently assigned to you.</p>
|
||||||
|
<p class="text-sm">Your foster coordinator will assign animals when they are ready.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||||
|
{#each data.assignments as a}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
{#if a.photoUrl}
|
||||||
|
<img src={a.photoUrl} alt={a.petName} class="w-full h-48 object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-48 bg-gray-100 flex items-center justify-center text-gray-300">
|
||||||
|
<svg class="w-16 h-16" 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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-gray-900">{a.petName}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">
|
||||||
|
{a.petBreed ?? 'Unknown breed'}
|
||||||
|
{#if a.petAge}
|
||||||
|
· {a.petAge}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">In foster since {formatDate(a.startDate)}</p>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<a
|
||||||
|
href="/foster/submit?petId={a.petId}"
|
||||||
|
class="flex-1 text-center text-sm py-2 px-3 rounded-lg font-medium text-white transition-colors"
|
||||||
|
style="background-color: var(--color-primary)"
|
||||||
|
>
|
||||||
|
Submit Update
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/foster/supplies?petId={a.petId}"
|
||||||
|
class="flex-1 text-center text-sm py-2 px-3 rounded-lg font-medium border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Supplies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Recent Submissions</h2>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
{#if data.recentSubmissions.length === 0}
|
||||||
|
<div class="p-8 text-center text-gray-400">
|
||||||
|
No recent submissions.
|
||||||
|
{#if data.assignments.length > 0}
|
||||||
|
<a href="/foster/submit" class="block mt-2 text-sm font-medium" style="color: var(--color-primary)">
|
||||||
|
Submit your first update
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
{#each data.recentSubmissions as sub}
|
||||||
|
<li class="px-5 py-3.5 flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold {statusBadgeClass(sub.status)}">
|
||||||
|
{sub.status}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-gray-900">
|
||||||
|
<span class="font-medium">{typeLabel(sub.type)}</span>
|
||||||
|
for <span class="font-medium">{sub.petName}</span>
|
||||||
|
{#if sub.content}
|
||||||
|
<span class="text-gray-400"> — {sub.content.length > 60 ? sub.content.slice(0, 60) + '...' : sub.content}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 whitespace-nowrap">{formatDate(sub.createdAt)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { fosterSubmissions, fosterAssignments, pets } from '$lib/server/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { saveUpload } from '$lib/server/upload';
|
||||||
|
import { getOrgConfig } from '$lib/server/config';
|
||||||
|
import { sendFosterSubmissionNotification } from '$lib/server/email';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
|
const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null;
|
||||||
|
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
petId: pets.id,
|
||||||
|
petName: pets.name
|
||||||
|
})
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.innerJoin(pets, eq(fosterAssignments.petId, pets.id))
|
||||||
|
.where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignments,
|
||||||
|
preselectedPetId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, locals }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
|
const fd = await request.formData();
|
||||||
|
|
||||||
|
const petId = parseInt(fd.get('petId')?.toString() ?? '0', 10);
|
||||||
|
const type = fd.get('type')?.toString() ?? 'update';
|
||||||
|
const content = fd.get('content')?.toString().trim() || null;
|
||||||
|
const photoFile = fd.get('photo') as File | null;
|
||||||
|
|
||||||
|
if (!petId) {
|
||||||
|
return fail(400, { error: 'Please select a pet.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['note', 'photo', 'update'].includes(type)) {
|
||||||
|
return fail(400, { error: 'Invalid submission type.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content && !photoFile?.size) {
|
||||||
|
return fail(400, { error: 'Please add a note or upload a photo.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pet is assigned to this foster
|
||||||
|
const [assignment] = await db
|
||||||
|
.select({ id: fosterAssignments.id })
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fosterAssignments.petId, petId),
|
||||||
|
eq(fosterAssignments.userId, userId),
|
||||||
|
eq(fosterAssignments.isCurrent, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return fail(403, { error: 'This pet is not assigned to you.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle photo upload
|
||||||
|
let photoUrl: string | null = null;
|
||||||
|
if (photoFile && photoFile.size > 0) {
|
||||||
|
try {
|
||||||
|
const uploaded = await saveUpload(photoFile, 'foster-submissions');
|
||||||
|
photoUrl = uploaded.url;
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Photo upload failed.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pet name for notification
|
||||||
|
const [pet] = await db
|
||||||
|
.select({ name: pets.name })
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.id, petId));
|
||||||
|
|
||||||
|
await db.insert(fosterSubmissions).values({
|
||||||
|
userId,
|
||||||
|
petId,
|
||||||
|
type: type as 'note' | 'photo' | 'update',
|
||||||
|
content,
|
||||||
|
photoUrl,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification email
|
||||||
|
try {
|
||||||
|
const config = await getOrgConfig();
|
||||||
|
if (config.orgInfo.email) {
|
||||||
|
await sendFosterSubmissionNotification(
|
||||||
|
config.orgInfo.name,
|
||||||
|
config.orgInfo.email,
|
||||||
|
locals.user!.name,
|
||||||
|
pet?.name ?? 'Unknown',
|
||||||
|
type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Email notification not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let showSuccess = $state(false);
|
||||||
|
let selectedType = $state('update');
|
||||||
|
|
||||||
|
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;
|
||||||
|
selectedType = 'update';
|
||||||
|
setTimeout(() => showSuccess = false, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgName = data.orgConfig?.orgInfo?.name ?? 'FosterFlow';
|
||||||
|
|
||||||
|
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-offset-0 text-sm';
|
||||||
|
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Submit Update — {orgName} Foster Portal</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<a href="/foster" class="text-sm hover:underline mb-4 inline-block" style="color: var(--color-primary)">← Back to Dashboard</a>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Submit an Update</h1>
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
Your update has been submitted and is pending review.
|
||||||
|
</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}
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
{#if data.assignments.length === 0}
|
||||||
|
<div class="text-center py-8 text-gray-400">
|
||||||
|
<p>No animals are currently assigned to you.</p>
|
||||||
|
<p class="text-sm mt-1">Contact your foster coordinator to be assigned a pet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" enctype="multipart/form-data" use:enhance={handleEnhance}>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- Pet selection -->
|
||||||
|
<div>
|
||||||
|
<label for="petId" class={labelClass}>
|
||||||
|
Which pet? <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="petId"
|
||||||
|
name="petId"
|
||||||
|
required
|
||||||
|
class={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Select a pet</option>
|
||||||
|
{#each data.assignments as a}
|
||||||
|
<option value={a.petId} selected={data.preselectedPetId === a.petId}>{a.petName}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type selection -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class={labelClass}>
|
||||||
|
Update type <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
bind:value={selectedType}
|
||||||
|
class={inputClass}
|
||||||
|
>
|
||||||
|
<option value="update">General Update</option>
|
||||||
|
<option value="note">Note</option>
|
||||||
|
<option value="photo">Photo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="content" class={labelClass}>
|
||||||
|
{selectedType === 'photo' ? 'Caption / notes' : 'Content'}
|
||||||
|
{selectedType !== 'photo' ? '*' : ''}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
rows="5"
|
||||||
|
required={selectedType !== 'photo'}
|
||||||
|
class="{inputClass} resize-y"
|
||||||
|
placeholder={selectedType === 'note' ? 'Add a note about this animal...' : selectedType === 'photo' ? 'Optional caption or description...' : 'Share an update about how this pet is doing...'}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo upload -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="photo" class={labelClass}>
|
||||||
|
Photo {selectedType === 'photo' ? '(required)' : '(optional)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="photo"
|
||||||
|
name="photo"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
required={selectedType === 'photo'}
|
||||||
|
class="w-full text-sm text-gray-600 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:text-white file:cursor-pointer cursor-pointer"
|
||||||
|
style="--tw-file-btn-bg: var(--color-primary)"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">JPG, PNG, or WebP. Max 10MB.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
style="background-color: var(--color-primary)"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Update'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { supplyRequests, fosterAssignments, pets } from '$lib/server/schema';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { getOrgConfig } from '$lib/server/config';
|
||||||
|
import { sendSupplyRequestNotification } from '$lib/server/email';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
|
const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null;
|
||||||
|
|
||||||
|
// All supply requests for this user
|
||||||
|
const requests = await db
|
||||||
|
.select({
|
||||||
|
id: supplyRequests.id,
|
||||||
|
items: supplyRequests.items,
|
||||||
|
urgency: supplyRequests.urgency,
|
||||||
|
notes: supplyRequests.notes,
|
||||||
|
status: supplyRequests.status,
|
||||||
|
createdAt: supplyRequests.createdAt,
|
||||||
|
petId: supplyRequests.petId,
|
||||||
|
petName: pets.name
|
||||||
|
})
|
||||||
|
.from(supplyRequests)
|
||||||
|
.leftJoin(pets, eq(supplyRequests.petId, pets.id))
|
||||||
|
.where(eq(supplyRequests.userId, userId))
|
||||||
|
.orderBy(desc(supplyRequests.createdAt));
|
||||||
|
|
||||||
|
// Current foster assignments for pet dropdown
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
petId: pets.id,
|
||||||
|
petName: pets.name
|
||||||
|
})
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.innerJoin(pets, eq(fosterAssignments.petId, pets.id))
|
||||||
|
.where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests,
|
||||||
|
assignments,
|
||||||
|
preselectedPetId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request, locals }) => {
|
||||||
|
const userId = locals.user!.id;
|
||||||
|
const fd = await request.formData();
|
||||||
|
|
||||||
|
const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null;
|
||||||
|
const items = fd.get('items')?.toString().trim() ?? '';
|
||||||
|
const urgency = fd.get('urgency')?.toString() ?? 'medium';
|
||||||
|
const notes = fd.get('notes')?.toString().trim() || null;
|
||||||
|
|
||||||
|
if (!items) {
|
||||||
|
return fail(400, { error: 'Please list the supplies you need.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['low', 'medium', 'high'].includes(urgency)) {
|
||||||
|
return fail(400, { error: 'Invalid urgency level.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If petId provided, verify pet is assigned to this foster
|
||||||
|
if (petId) {
|
||||||
|
const [assignment] = await db
|
||||||
|
.select({ id: fosterAssignments.id })
|
||||||
|
.from(fosterAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(fosterAssignments.petId, petId),
|
||||||
|
eq(fosterAssignments.userId, userId),
|
||||||
|
eq(fosterAssignments.isCurrent, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return fail(403, { error: 'This pet is not assigned to you.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(supplyRequests).values({
|
||||||
|
userId,
|
||||||
|
petId: petId || null,
|
||||||
|
items,
|
||||||
|
urgency: urgency as 'low' | 'medium' | 'high',
|
||||||
|
notes,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification email
|
||||||
|
try {
|
||||||
|
const config = await getOrgConfig();
|
||||||
|
if (config.orgInfo.email) {
|
||||||
|
await sendSupplyRequestNotification(
|
||||||
|
config.orgInfo.name,
|
||||||
|
config.orgInfo.email,
|
||||||
|
locals.user!.name,
|
||||||
|
items,
|
||||||
|
urgency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Email notification not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showSuccess = true;
|
||||||
|
setTimeout(() => showSuccess = false, 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | Date): string {
|
||||||
|
return new Date(d).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'text-yellow-700 bg-yellow-100';
|
||||||
|
case 'approved': return 'text-blue-700 bg-blue-100';
|
||||||
|
case 'fulfilled': return 'text-green-700 bg-green-100';
|
||||||
|
case 'denied': return 'text-red-700 bg-red-100';
|
||||||
|
default: return 'text-gray-700 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyBadgeClass(urgency: string): string {
|
||||||
|
switch (urgency) {
|
||||||
|
case 'high': return 'text-red-700 bg-red-100';
|
||||||
|
case 'medium': return 'text-yellow-700 bg-yellow-100';
|
||||||
|
case 'low': return 'text-gray-600 bg-gray-100';
|
||||||
|
default: return 'text-gray-700 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgName = data.orgConfig?.orgInfo?.name ?? 'FosterFlow';
|
||||||
|
|
||||||
|
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-offset-0 text-sm';
|
||||||
|
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Request Supplies — {orgName} Foster Portal</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<a href="/foster" class="text-sm hover:underline mb-4 inline-block" style="color: var(--color-primary)">← Back to Dashboard</a>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">Request Supplies</h1>
|
||||||
|
|
||||||
|
{#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>
|
||||||
|
Supply request submitted. Your coordinator will be notified.
|
||||||
|
</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}
|
||||||
|
|
||||||
|
<!-- New Request 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">New Request</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="?/create" use:enhance={handleEnhance}>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<!-- Items needed -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="items" class={labelClass}>
|
||||||
|
Items needed <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="items"
|
||||||
|
name="items"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
class="{inputClass} resize-y"
|
||||||
|
placeholder="e.g., Puppy food (small breed), crate pad, Kong toy..."
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">List everything you need, one item per line or separated by commas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pet selection (optional) -->
|
||||||
|
{#if data.assignments.length > 0}
|
||||||
|
<div>
|
||||||
|
<label for="petId" class={labelClass}>For which pet? (optional)</label>
|
||||||
|
<select id="petId" name="petId" class={inputClass}>
|
||||||
|
<option value="">Not specific to one pet</option>
|
||||||
|
{#each data.assignments as a}
|
||||||
|
<option value={a.petId} selected={data.preselectedPetId === a.petId}>{a.petName}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Urgency -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Urgency</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="urgency" value="low" class="w-4 h-4" />
|
||||||
|
<span class="text-sm text-gray-700">Low</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="urgency" value="medium" checked class="w-4 h-4" />
|
||||||
|
<span class="text-sm text-gray-700">Medium</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="urgency" value="high" class="w-4 h-4" />
|
||||||
|
<span class="text-sm text-gray-700">High</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="notes" class={labelClass}>Additional notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
rows="2"
|
||||||
|
class="{inputClass} resize-y"
|
||||||
|
placeholder="Any additional context or preferences..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
style="background-color: var(--color-primary)"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Request'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Past Requests -->
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Your Requests</h2>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
{#if data.requests.length === 0}
|
||||||
|
<div class="p-8 text-center text-gray-400">No supply requests yet.</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Mobile list -->
|
||||||
|
<ul class="divide-y divide-gray-100 sm:hidden">
|
||||||
|
{#each data.requests as req}
|
||||||
|
<li class="px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="font-medium text-sm text-gray-900 truncate max-w-[60%]">{req.items.length > 50 ? req.items.slice(0, 50) + '...' : req.items}</span>
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold {statusBadgeClass(req.status)}">{req.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
{#if req.petName}
|
||||||
|
<span>{req.petName}</span>
|
||||||
|
<span>·</span>
|
||||||
|
{/if}
|
||||||
|
<span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs {urgencyBadgeClass(req.urgency)}">{req.urgency}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDate(req.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Desktop table -->
|
||||||
|
<div class="hidden sm:block overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-100 bg-gray-50">
|
||||||
|
<th class="text-left px-5 py-3 font-medium text-gray-500">Date</th>
|
||||||
|
<th class="text-left px-5 py-3 font-medium text-gray-500">Items</th>
|
||||||
|
<th class="text-left px-5 py-3 font-medium text-gray-500">Pet</th>
|
||||||
|
<th class="text-left px-5 py-3 font-medium text-gray-500">Urgency</th>
|
||||||
|
<th class="text-left px-5 py-3 font-medium text-gray-500">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{#each data.requests as req}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-5 py-3 text-gray-500 whitespace-nowrap">{formatDate(req.createdAt)}</td>
|
||||||
|
<td class="px-5 py-3 text-gray-900 max-w-xs truncate">{req.items}</td>
|
||||||
|
<td class="px-5 py-3 text-gray-600">{req.petName ?? '—'}</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold {urgencyBadgeClass(req.urgency)}">{req.urgency}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold {statusBadgeClass(req.status)}">{req.status}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user