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:
parent
1b1566e6d7
commit
a3b25d5e46
4 changed files with 307 additions and 2 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
221
frontend/src/routes/routines/grooming-schedule/+page.svelte
Normal file
221
frontend/src/routes/routines/grooming-schedule/+page.svelte
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue