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:
2026-03-26 12:53:57 -05:00
co-authored by Claude Sonnet 4.6
parent 19665c4dd6
commit 6e3ca2e496
8 changed files with 937 additions and 0 deletions
+40
View File
@@ -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
};
};
+91
View File
@@ -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>
+70
View File
@@ -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
};
};
+159
View File
@@ -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}
&middot; {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>
+111
View File
@@ -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 };
}
};
+142
View File
@@ -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)">&larr; 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>
+110
View File
@@ -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 };
}
};
+214
View File
@@ -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)">&larr; 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>&middot;</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>&middot;</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>