feat(frontend): add grooming schedule CRUD page

- New route /routines/grooming-schedule with load + create/update/delete actions
- Entries grouped by day of week with inline editing
- 4 API functions added to api.ts (get/create/update/delete)
- Nav: add Grooming link, fix isActive to not highlight parent when child matches
- Nav: replace ⌂ text char with 🏠 emoji for Dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-28 23:46:33 +01:00
parent 1b1566e6d7
commit a3b25d5e46
4 changed files with 307 additions and 2 deletions

View file

@ -1,6 +1,7 @@
import { PUBLIC_API_BASE } from '$env/static/public'; import { PUBLIC_API_BASE } from '$env/static/public';
import type { import type {
ActiveIngredient, ActiveIngredient,
GroomingSchedule,
LabResult, LabResult,
MedicationEntry, MedicationEntry,
MedicationUsage, MedicationUsage,
@ -129,6 +130,15 @@ export const updateRoutineStep = (stepId: string, body: Record<string, unknown>)
export const deleteRoutineStep = (stepId: string): Promise<void> => export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`); api.del(`/routines/steps/${stepId}`);
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get('/routines/grooming-schedule');
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
api.post('/routines/grooming-schedule', body);
export const updateGroomingScheduleEntry = (id: string, body: Record<string, unknown>): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body);
export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`);
// ─── Health Medications ──────────────────────────────────────────────────── // ─── Health Medications ────────────────────────────────────────────────────
export interface MedicationListParams { export interface MedicationListParams {

View file

@ -5,9 +5,10 @@
let { children } = $props(); let { children } = $props();
const navItems = [ const navItems = [
{ href: '/', label: 'Dashboard', icon: '' }, { href: '/', label: 'Dashboard', icon: '🏠' },
{ href: '/products', label: 'Products', icon: '🧴' }, { href: '/products', label: 'Products', icon: '🧴' },
{ href: '/routines', label: 'Routines', icon: '📋' }, { href: '/routines', label: 'Routines', icon: '📋' },
{ href: '/routines/grooming-schedule', label: 'Grooming', icon: '🪒' },
{ href: '/health/medications', label: 'Medications', icon: '💊' }, { href: '/health/medications', label: 'Medications', icon: '💊' },
{ href: '/health/lab-results', label: 'Lab Results', icon: '🔬' }, { href: '/health/lab-results', label: 'Lab Results', icon: '🔬' },
{ href: '/skin', label: 'Skin', icon: '✨' } { href: '/skin', label: 'Skin', icon: '✨' }
@ -15,7 +16,13 @@
function isActive(href: string) { function isActive(href: string) {
if (href === '/') return page.url.pathname === '/'; if (href === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(href); const pathname = page.url.pathname;
if (!pathname.startsWith(href)) return false;
// Don't mark parent as active if a more-specific nav item also matches
const moreSpecific = navItems.some(
(item) => item.href !== href && item.href.startsWith(href) && pathname.startsWith(item.href)
);
return !moreSpecific;
} }
</script> </script>

View file

@ -0,0 +1,67 @@
import {
createGroomingScheduleEntry,
deleteGroomingScheduleEntry,
getGroomingSchedule,
updateGroomingScheduleEntry
} from '$lib/api';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const schedule = await getGroomingSchedule();
return { schedule };
};
export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const day_of_week = form.get('day_of_week');
const action = form.get('action') as string;
if (day_of_week === null || !action) {
return fail(400, { error: 'day_of_week and action are required' });
}
const body: Record<string, unknown> = {
day_of_week: Number(day_of_week),
action
};
const notes = (form.get('notes') as string)?.trim();
if (notes) body.notes = notes;
try {
await createGroomingScheduleEntry(body);
return { created: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
update: async ({ request }) => {
const form = await request.formData();
const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' });
const body: Record<string, unknown> = {};
const day_of_week = form.get('day_of_week');
if (day_of_week !== null) body.day_of_week = Number(day_of_week);
const action = form.get('action') as string;
if (action) body.action = action;
const notes = (form.get('notes') as string)?.trim();
body.notes = notes || null;
try {
await updateGroomingScheduleEntry(id, body);
return { updated: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
delete: async ({ request }) => {
const form = await request.formData();
const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' });
try {
await deleteGroomingScheduleEntry(id);
return { deleted: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
}
};

View file

@ -0,0 +1,221 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import type { GroomingAction, GroomingSchedule } from '$lib/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { schedule } = $derived(data);
let showAddForm = $state(false);
let editingId = $state<string | null>(null);
const DAY_NAMES = ['Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota', 'Niedziela'];
const ACTION_LABELS: Record<GroomingAction, string> = {
shaving_razor: 'Golenie maszynką',
shaving_oneblade: 'Golenie OneBlade',
dermarolling: 'Dermarolling'
};
const ALL_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
const byDay = $derived(
DAY_NAMES.map((name, idx) => ({
name,
day: idx,
entries: schedule.filter((e: GroomingSchedule) => e.day_of_week === idx)
})).filter((d) => d.entries.length > 0)
);
</script>
<svelte:head><title>Grooming Schedule — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<div>
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Routines</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">Grooming Schedule</h2>
</div>
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
{showAddForm ? 'Anuluj' : '+ Dodaj wpis'}
</Button>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
{/if}
{#if form?.created}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Wpis dodany.</div>
{/if}
{#if form?.updated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Wpis zaktualizowany.</div>
{/if}
{#if form?.deleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Wpis usunięty.</div>
{/if}
<!-- Add form -->
{#if showAddForm}
<Card>
<CardContent class="pt-4">
<form
method="POST"
action="?/create"
use:enhance={() => {
return async ({ update }) => {
await update();
showAddForm = false;
};
}}
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1">
<Label for="add_day">Dzień tygodnia</Label>
<select
id="add_day"
name="day_of_week"
required
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each DAY_NAMES as name, idx (idx)}
<option value={idx}>{name}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label for="add_action">Czynność</Label>
<select
id="add_action"
name="action"
required
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each ALL_ACTIONS as action (action)}
<option value={action}>{ACTION_LABELS[action]}</option>
{/each}
</select>
</div>
<div class="col-span-2 space-y-1">
<Label for="add_notes">Notatki (opcjonalnie)</Label>
<Input id="add_notes" name="notes" placeholder="np. co 2 tygodnie" />
</div>
<div class="col-span-2 flex gap-2">
<Button type="submit" size="sm">Dodaj</Button>
<Button type="button" variant="ghost" size="sm" onclick={() => (showAddForm = false)}>
Anuluj
</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<!-- Entries grouped by day -->
{#if schedule.length === 0}
<p class="text-sm text-muted-foreground">Brak wpisów. Kliknij "+ Dodaj wpis", aby zacząć.</p>
{:else}
<div class="space-y-4">
{#each byDay as { name, entries, day } (day)}
<div class="space-y-2">
<h3 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{name}</h3>
{#each entries as entry (entry.id)}
<div class="rounded-md border border-border text-sm">
<!-- Entry row -->
<div class="flex items-center justify-between px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{ACTION_LABELS[entry.action]}</Badge>
{#if entry.notes}
<span class="text-muted-foreground">{entry.notes}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => (editingId = editingId === entry.id ? null : entry.id)}
>
{editingId === entry.id ? 'Anuluj' : 'Edytuj'}
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm('Usunąć ten wpis?')) e.preventDefault();
}}
>
<input type="hidden" name="id" value={entry.id} />
<Button variant="ghost" size="sm" type="submit" class="text-destructive hover:text-destructive">
Usuń
</Button>
</form>
</div>
</div>
<!-- Inline edit form -->
{#if editingId === entry.id}
<div class="border-t border-border bg-muted/30 px-4 py-3">
<form
method="POST"
action="?/update"
use:enhance={() => {
return async ({ update }) => {
await update();
editingId = null;
};
}}
class="grid grid-cols-2 gap-3"
>
<input type="hidden" name="id" value={entry.id} />
<div class="space-y-1">
<Label>Dzień tygodnia</Label>
<select
name="day_of_week"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each DAY_NAMES as dayName, idx (idx)}
<option value={idx} selected={entry.day_of_week === idx}>{dayName}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>Czynność</Label>
<select
name="action"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each ALL_ACTIONS as a (a)}
<option value={a} selected={entry.action === a}>{ACTION_LABELS[a]}</option>
{/each}
</select>
</div>
<div class="col-span-2 space-y-1">
<Label>Notatki</Label>
<Input name="notes" value={entry.notes ?? ''} placeholder="opcjonalnie" />
</div>
<div class="col-span-2 flex gap-2">
<Button type="submit" size="sm">Zapisz</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => (editingId = null)}
>
Anuluj
</Button>
</div>
</form>
</div>
{/if}
</div>
{/each}
</div>
<Separator />
{/each}
</div>
{/if}
</div>