add fixes to claims and seasons

This commit is contained in:
JCEEE
2026-07-30 11:12:35 +01:00
parent 57ef30d3a7
commit b254564436
24 changed files with 2517 additions and 704 deletions
+27 -2
View File
@@ -1,4 +1,29 @@
export function load(event) {
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session || null;
return { session, isParent: session !== null, role: session?.role || 'child' };
const isParent = session !== null;
let famId = '';
if (isParent) {
famId = session.famId;
} else {
const deviceToken = event.cookies.get('device_token') || '';
if (deviceToken) {
try {
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
headers: { 'x-device-token': deviceToken },
});
if (res.ok) {
const body = await res.json();
famId = body.famId || '';
}
} catch {}
}
}
return { session, isParent, role: session?.role || 'child', famId };
}
+28 -12
View File
@@ -10,7 +10,9 @@
let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent);
let role = $state(data.role);
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam);
let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
);
// Claim toast watcher (admin only)
let claimToast = $state('');
@@ -22,31 +24,29 @@
const rewards = famStore.rewards;
if (!mounted) {
mounted = true;
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
return;
}
for (const r of rewards) {
if (r.claimed && !prevClaimedIds.has(r.id)) {
if (r.status === 'requested' && !prevClaimedIds.has(r.id)) {
const m = famStore.memberMap().get(r.memberId);
const name = m?.name || 'Unknown';
claimToast = `Claim made by ${name}`;
setTimeout(() => claimToast = '', 5000);
claimToast = `Claim requested by ${name}`;
setTimeout(() => (claimToast = ''), 5000);
}
}
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
});
onMount(() => {
initPbFromCookie();
if (session) {
famStore.init(session.famId);
}
if (page.data.famId) famStore.init(page.data.famId);
});
</script>
<div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav />
<TopNav {role} seasons={famStore.seasons} />
<main class="app-main">
{@render children()}
</main>
@@ -57,8 +57,24 @@
</div>
<style>
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.claim-toast {
position: fixed;
top: 3.5rem;
right: 1.5rem;
background: #059669;
color: white;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
z-index: 999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadein 0.2s;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-main {
margin-left: 220px;
margin-top: 48px;
@@ -92,6 +92,20 @@ export const actions = {
}
},
issueAll: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const memberId = fd.get('memberId') as string;
const message = fd.get('message') as string;
try {
const result = await hono.admin.issueAllRewards(event, famId, memberId, message || undefined);
return { count: result.count };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
}
},
revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
File diff suppressed because it is too large Load Diff
@@ -57,9 +57,9 @@ import type {
let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]);
let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]);
let completedConfigs = $derived(configs.filter((c) => c.phase === 'completed') as BonusConfig[]);
let completedRewards = $derived(allRewards.filter((r) => r.claimed) as Reward[]);
let completedRewards = $derived(allRewards.filter((r) => r.status === 'claimed') as Reward[]);
let unclaimedRewards = $derived(
allRewards.filter((r) => !r.claimed) as Reward[]
allRewards.filter((r) => r.status === 'unclaimed') as Reward[]
);
let progressByConfigId = $derived.by(() => {
const map = new Map<string, BonusConfigWithProgress>();
@@ -4,12 +4,13 @@ import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [templates, members, assigned] = await Promise.all([
const [templates, members, assigned, seasons] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.list(event, 'seasons', famId),
]);
return { templates, members, assigned };
return { templates, members, assigned, seasons };
}
export const actions = {
@@ -80,29 +81,6 @@ export const actions = {
}
},
assignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
memberId: fd.get('memberId'),
templateId: fd.get('templateId'),
frequency: fd.get('frequency'),
type: fd.get('type'),
value: parseFloat(fd.get('value') as string) || 0,
customName: fd.get('customName') || undefined,
};
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
return fail(400, { error: 'Member, template, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
}
},
updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
@@ -113,10 +91,14 @@ export const actions = {
const type = fd.get('type');
const value = fd.get('value');
const customName = fd.get('customName');
const seasonIdsRaw = fd.get('seasonIds') as string;
if (frequency) data.frequency = frequency;
if (type) data.type = type;
if (value) data.value = parseFloat(value as string) || 0;
data.customName = (customName as string) || undefined;
if (seasonIdsRaw || seasonIdsRaw === '') {
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
}
try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
return { record };
@@ -125,16 +107,4 @@ export const actions = {
}
},
unassignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
}
},
};
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types';
import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
let { data, form } = $props();
@@ -30,18 +30,45 @@
let editTplType = $state('points')
let editTplValue = $state(10)
let seasonFilter = $state('all')
let editSeasonIds = $state<string[]>([])
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
let seasons = $state(famStore.initialized ? famStore.seasons : (data.seasons || []))
let filteredAssigned = $derived(
seasonFilter === 'all'
? assigned
: assigned.filter(a => a.seasonIds?.includes(seasonFilter))
)
function assignedForMember(memberId: string): AssignedChore[] {
return filteredAssigned.filter(a => a.memberId === memberId)
}
function assignedForMemberAll(memberId: string): AssignedChore[] {
return assigned.filter(a => a.memberId === memberId)
}
function isGlobalChore(a: AssignedChore): boolean {
return !a.seasonIds || a.seasonIds.length === 0
}
function templateName(templateId: string): string {
return famStore.templateMap().get(templateId)?.name || '?'
}
function seasonName(id: string): string {
return seasons.find(s => s.id === id)?.name || id
}
function seasonNames(a: AssignedChore): string {
return (a.seasonIds || []).map(id => seasonName(id)).join(', ') || 'Global'
}
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
const t = famStore.templateMap().get(templateId)
if (!t) return { frequency: 'daily', type: 'points', value: 10 }
@@ -65,25 +92,16 @@
if (!tid) return
draggedTemplateId = null
const s = page.data.session as any
if (!s) return
const def = getDefault(tid)
const fd = new FormData()
fd.set('memberId', memberId)
fd.set('templateId', tid)
fd.set('frequency', def.frequency)
fd.set('type', def.type)
fd.set('value', String(def.value))
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
const body: Record<string, unknown> = { memberId, templateId: tid, frequency: def.frequency, type: def.type, value: def.value }
if (seasonFilter !== 'all') body.seasonIds = [seasonFilter]
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
method: 'POST',
body: fd,
headers: { 'Content-Type': 'application/json', 'x-session-famid': s.famId, 'x-session-userid': s.userId },
body: JSON.stringify(body),
})
if (res.ok) {
const body = await res.json()
if (body.record) {
assigned = [body.record as AssignedChore, ...assigned]
famStore.applyRecord('assigned_chores', body.record, 'create')
}
}
}
function openEdit(a: AssignedChore) {
@@ -92,6 +110,7 @@
editType = a.type
editValue = a.value
editCustomName = a.customName || ''
editSeasonIds = a.seasonIds ? [...a.seasonIds] : []
showEditModal = true
}
@@ -138,6 +157,7 @@
type: editType,
value: editValue,
customName: editCustomName || undefined,
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
} as AssignedChore
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
}
@@ -179,6 +199,16 @@
<p class="error">{form.error}</p>
{/if}
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each seasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
<CardGrid>
<Card cols={3}>
<div style="margin-bottom:0.75rem">
@@ -233,12 +263,28 @@
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
{/if}
</div>
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="del-btn" title="Remove assignment">×</button>
</form>
<div class="card-meta">
<span class="season-tags">{seasonNames(a)}</span>
</div>
<button
class="del-btn"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
const res = await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId },
});
if (res.ok) famStore.applyRecord('assigned_chores', { id: a.id }, 'delete');
}}
>×</button>
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
@@ -333,6 +379,7 @@
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
<input name="id" type="hidden" value={editingAssignment.id} />
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
<label>
Custom Name
<input name="customName" bind:value={editCustomName} placeholder="Override template name" />
@@ -351,11 +398,33 @@
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<div class="modal-actions">
<label>
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<label>
Seasons
<div class="season-checklist">
{#each seasons as s}
<label class="check-item">
<input type="checkbox" checked={editSeasonIds.includes(s.id)} onchange={() => {
if (editSeasonIds.includes(s.id)) {
editSeasonIds = editSeasonIds.filter(x => x !== s.id);
} else {
editSeasonIds = [...editSeasonIds, s.id];
}
}} />
<span class="dot" style="background:{s.color}"></span>
{s.name}
</label>
{/each}
<label class="check-item">
<input type="checkbox" checked={editSeasonIds.length === 0} onchange={() => editSeasonIds = []} />
Global (no season)
</label>
</div>
</label>
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button>
<button type="submit">Save</button>
</div>
@@ -384,6 +453,15 @@
.edit-btn:hover { color: #6366f1; }
.del-btn:hover { color: #dc2626; }
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
.season-filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.season-filter label { font-size: 0.85rem; color: #374151; font-weight: 500; }
.season-filter select { padding: 0.35rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; }
.badge.season { background: #e0f2fe; color: #0369a1; }
.card-meta { font-size: 0.7rem; color: #9ca3af; flex-shrink: 0; }
.season-tags { font-size: 0.7rem; color: #9ca3af; }
.season-checklist { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.25rem; }
.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; cursor: pointer; }
.check-item input[type="checkbox"] { width: auto; margin: 0; }
.template { cursor: grab; border-left: 3px solid #6366f1; }
.template:active { cursor: grabbing; opacity: 0.8; }
@@ -23,12 +23,14 @@
}
function rewardStatus(r: any): string {
if (!r.claimed) return 'Outstanding';
if (r.status === 'unclaimed') return 'Outstanding';
if (r.status === 'requested') return 'Requested';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
function isOutstanding(r: any): boolean { return !r.claimed; }
function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
function isRequested(r: any): boolean { return r.status === 'requested'; }
let sorted = $derived([...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || '';
@@ -50,7 +52,7 @@
let totalOutstanding = $derived(
rewards
.filter((r: any) => isOutstanding(r))
.filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
)
@@ -81,7 +83,7 @@
<tbody>
{#each sorted as r}
{@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding}>
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
<td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
@@ -90,7 +92,7 @@
<td>{r.label}</td>
<td class="num">{rewardAmount(r)}</td>
<td>
<span class="status-badge" class:outstanding class:claimed-status={!outstanding}>
<span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
{rewardStatus(r)}
</span>
</td>
@@ -129,9 +131,11 @@
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
tr.claimed { opacity: 0.5; }
tr.claimed td { color: #9ca3af; }
tr.requested { background: #eff6ff; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
</style>
@@ -5,11 +5,12 @@ import type { RequestEvent } from '@sveltejs/kit';
export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [members, fam] = await Promise.all([
const [members, fam, seasons] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.fam(event, famId),
hono.admin.list(event, 'seasons', famId),
]);
return { members, fam };
return { members, fam, seasons };
}
export const actions = {
@@ -54,4 +55,59 @@ export const actions = {
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
return await hono.admin.updatePayday(event, famId, payday);
},
createSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
if (!name) return { error: 'Name required' };
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
},
deleteSeason: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
if (!id) return { error: 'Season ID required' };
const assigned = await hono.admin.list(event, 'assigned-chores', famId);
const toDelete = (Array.isArray(assigned) ? assigned : [])
.filter((a: any) => a.seasonIds?.includes(id));
const deletedIds = toDelete.map((a: any) => a.id);
await Promise.all(toDelete.map((a: any) =>
hono.admin.remove(event, 'assigned-chores', famId, a.id)
));
await hono.admin.remove(event, 'seasons', famId, id);
return { deletedChoreIds: deletedIds };
},
completeWeek: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
return { success: true, result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
}
},
generateData: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const days = parseInt(fd.get('days') as string || '7', 10);
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
return { success: true, result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
}
},
};
@@ -28,6 +28,7 @@ import { onDestroy } from 'svelte';
)
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
let deletingSeason = $state<any>(null)
function copy(url: string) {
navigator.clipboard.writeText(url)
@@ -140,12 +141,73 @@ import { onDestroy } from 'svelte';
<p class="hint">They will set up their own password on first login.</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="inline">
<input name="name" placeholder="Season name" required />
<input name="color" type="color" value="#6366f1" class="color-input" />
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => deletingSeason = s}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => deletingSeason = null} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
<form method="POST" action="?/deleteSeason" use:enhance={() => { return async ({ result }) => {
if (result.type === 'success') {
const r = result.data;
if (r?.deletedChoreIds) {
r.deletedChoreIds.forEach((id: string) => famStore.applyRecord('assigned_chores', { id }, 'delete'));
}
famStore.applyRecord('seasons', { id: deletingSeason.id }, 'delete');
deletingSeason = null;
}
}}}>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => deletingSeason = null}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
</div>
</form>
</div>
</div>
{/if}
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
{#if data.fam?.featureFlags?.debugMode}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form method="POST" action="?/completeWeek" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Week completed!'); await update(); }; }}>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form method="POST" action="?/generateData" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Test data generated!'); await update(); }; }}>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
</CardGrid>
<style>
@@ -168,4 +230,13 @@ import { onDestroy } from 'svelte';
button:disabled { opacity: 0.5; cursor: not-allowed; }
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
.inline { display: inline; margin: 0; }
.color-input { width: 40px; height: 34px; padding: 0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.modal h3 { margin: 0 0 0.75rem; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
.modal-actions button[type="submit"] { background: #dc2626; color: white; }
.warning { color: #dc2626; font-size: 0.9rem; background: #fef2f2; padding: 0.6rem; border-radius: 6px; margin-bottom: 0.5rem; }
</style>