Files
famdone/frontend/src/routes/[fam]/[username]/+page.svelte
T
2026-07-29 09:41:52 +01:00

814 lines
33 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
interface Notification {
id: string
famId: string
memberId: string
message: string
read: boolean
created: string
}
let { data } = $props();
let role = $state(data.role || 'child')
let famSlug = $derived(page.params.fam)
let username = $derived(page.params.username)
// ─── Parent View (admin overview) ───
let summary = $state(data.summary)
let today = $state(new Date().toISOString().slice(0, 10))
const responseTags = ['👏 well done', '😊 really pleased', '🎯 you deserved that', '💪 great effort', '🙏 thanks']
let selectedMessage = $state<Record<string, string>>({})
let customMessage = $state<Record<string, string>>({})
let toast = $state('')
let parentMembers = $derived(famStore.initialized ? famStore.members : (data.members || []))
let parentTemplates = $derived(famStore.initialized ? famStore.templates : (data.templates || []))
let parentAssigned = $derived(famStore.initialized ? famStore.assigned : (data.assigned || []))
let parentRewards = $derived(famStore.initialized ? famStore.rewards : (data.rewards || []))
let parentCompletions = $derived(famStore.initialized ? famStore.completions : (data.completions || []))
let parentBonusConfigs = $derived(famStore.initialized ? famStore.bonusConfigs : (data.bonusConfigs || []))
function totalChoresFor(memberId: string): number {
return parentAssigned.filter((a: any) => a.memberId === memberId).length
}
function todayCompletionsFor(memberId: string) {
return parentCompletions.filter((c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today)
}
function choreNameFor(assignedChoreId: string): string {
const a = parentAssigned.find((x: any) => x.id === assignedChoreId)
if (!a) return '?'
const t = parentTemplates.find((x: any) => x.id === a.templateId)
return a.customName || t?.name || '?'
}
function memberInSummary(memberId: string) {
return summary?.summaries?.find((s: any) => s.memberId === memberId)
}
function claimableRewards() {
return parentRewards.filter((r: any) => !r.claimed)
}
function memberClaimable(memberId: string) {
return claimableRewards().filter((r: any) => r.memberId === memberId)
}
let _confetti: any;
async function fire() {
if (!_confetti) _confetti = await import('@hiseb/confetti').then(m => m.default);
_confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => toast = '', 4000);
}
function handleResult({ result }, onSuccess = null) {
const d = result.data || {};
if (d.error) showToast(d.error);
else if (result.type === 'success') {
if (onSuccess) onSuccess(d);
fire();
}
}
function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`
if (r.rewardType === 'points') return `${r.value} pts`
return r.label
}
function manualConfigs() {
return parentBonusConfigs.filter((b: any) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual')
}
// ─── Child View (kanban) ───
let memberId = $state(data.memberId || '')
let deviceToken = $state(data.token || '')
let famId = $state(data.famId || '')
let memberName = $state(data.memberName || '')
let memberColor = $state(data.memberColor || '#6366f1')
let editingName = $state(false)
let nameInput = $state('')
let showColorPicker = $state(false)
let templates = $state(data.templates as ChoreTemplate[])
let assigned = $state(data.assigned as AssignedChore[])
let completions = $state(data.completions as Completion[])
let rewards = $state(data.rewards as Reward[])
let bonusConfigs = $state(data.bonusConfigs as BonusConfig[])
let loading = $state(true)
let error = $state('')
let dailyPending = $state<AssignedChore[]>([])
let weeklyPending = $state<AssignedChore[]>([])
let completedToday = $state<Completion[]>([])
let claimableRewardsChild = $state<Reward[]>([])
let requestedRewards = $state<string[]>([])
let claimError = $state('')
let todayChild = $state(new Date().toISOString().slice(0, 10))
let togglingIds = $state<string>('')
let bonusNotices = $state<Reward[]>([])
let totalCash = $state(0)
let pendingCash = $state(0)
let totalPoints = $state(0)
let weeklyTotal = $state(0)
let weeklyDone = $state(0)
let bonusProgresses = $state<{ config: BonusConfig; current: number; criteria: number; periodStart: string; periodEnd: string }[]>([])
function weekStartStr(): string {
const d = new Date()
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
return d.toISOString().slice(0, 10)
}
function monthStartStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`
}
onMount(async () => {
if (role === 'parent') return;
if (!deviceToken) {
deviceToken = localStorage.getItem('deviceToken') || ''
} else {
localStorage.setItem('deviceToken', deviceToken)
}
if (!deviceToken) {
error = 'No device token found. Use the link from your invite.'
loading = false
return
}
if (data.verified && data.famId && data.memberId) {
memberId = data.memberId
famId = data.famId
loading = false
} else {
error = 'Invalid device token. Use the link from your invite.'
loading = false
return
}
await loadAndDismiss()
})
async function loadAndDismiss() {
if (!deviceToken || !famId) return
try {
const res = await fetch('/api/members/notifications', {
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId,
},
})
if (!res.ok) return
const all: Notification[] = await res.json()
for (const n of all.filter(n => !n.read)) {
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
method: 'POST',
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId,
},
})
}
} catch {}
}
$effect(() => {
if (role === 'parent' || !memberId) return
const memberChores = assigned.filter(a => a.memberId === memberId)
dailyPending = memberChores.filter(a => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
weeklyPending = memberChores.filter(a => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
completedToday = completions.filter(c => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
claimableRewardsChild = rewards.filter(r =>
r.memberId === memberId && !r.claimed
)
const ws = weekStartStr()
const dailyChores = memberChores.filter(a => a.frequency === 'daily')
const weeklyChores = memberChores.filter(a => a.frequency === 'weekly')
weeklyTotal = dailyChores.length * 7 + weeklyChores.length
weeklyDone = completions.filter(
c => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
).length
const myRewards = rewards.filter(r => r.memberId === memberId)
const myCompletions = completions.filter(c => c.memberId === memberId)
bonusNotices = myRewards.filter(r => r.claimed && r.claimedAt?.slice(0, 10) === r.date)
totalCash = myCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'money' ? Number(chore.value) : 0)
}, 0) + myRewards.filter(r => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
pendingCash = myRewards.filter(r => r.rewardType === 'cash' && !r.claimed).reduce((sum, r) => sum + Number(r.value), 0)
totalPoints = myCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
}, 0) + myRewards.filter(r => r.rewardType === 'points').reduce((sum, r) => sum + Number(r.value), 0)
const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.target === 'individual' && (!b.memberId || b.memberId === memberId)
)
bonusProgresses = activeConfigs.map(cfg => {
const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''
const pEnd = pStart ? (() => {
if (cfg.period === 'weekly') {
const d = new Date(pStart)
d.setDate(d.getDate() + 6)
return d.toISOString().slice(0, 10)
}
if (cfg.period === 'monthly') {
const [y, m] = pStart.split('-').map(Number)
const lastDay = new Date(y, m, 0).getDate()
return `${pStart.slice(0, 7)}-${String(lastDay).padStart(2, '0')}`
}
return ''
})() : ''
const periodCompletions = pStart
? myCompletions.filter(c => (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd)
: myCompletions
let current = 0
if (cfg.type === 'threshold') {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find(a => a.id === c.assignedChoreId)
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
}, 0)
} else if (cfg.type === 'count') {
current = periodCompletions.length
}
return { config: cfg, current, criteria: cfg.criteriaValue || 0, periodStart: pStart, periodEnd: pEnd }
})
})
function isCompleted(assignedChoreId: string, date: string): boolean {
return completions.some(c => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date)
}
function findCompletion(assignedChoreId: string) {
const match = (c: Completion) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild
const optimistic = completions.find(c => match(c) && c.id.startsWith('optimistic-'))
return optimistic || completions.find(match)
}
async function toggle(chore: AssignedChore) {
if (togglingIds) return
togglingIds = chore.id
const wasCompleted = isCompleted(chore.id, todayChild)
if (wasCompleted) {
const existing = findCompletion(chore.id)
if (existing) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
} else {
completions = [...completions, {
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
}
try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild)
completions = completions.filter(c =>
c.assignedChoreId !== chore.id || (c.date?.slice(0, 10) || c.date) !== todayChild || c.id.startsWith('optimistic-')
)
} catch (e) {
if (wasCompleted) {
completions = [...completions, {
id: 'revert-' + chore.id + '-' + Date.now(),
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
} else {
const existing = findCompletion(chore.id)
if (existing && existing.id.startsWith('optimistic-')) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
}
console.error('Toggle failed:', e)
} finally {
togglingIds = ''
}
}
function templateFor(chore: AssignedChore): ChoreTemplate | undefined {
return templates.find(t => t.id === chore.templateId)
}
function choreName(chore: AssignedChore): string {
return chore.customName || templateFor(chore)?.name || 'Chore'
}
function rewardLabelChild(r: Reward): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`
if (r.rewardType === 'points') return `${r.value} pts`
return r.label
}
</script>
{#if role === 'parent'}
<!-- Parent View: Admin Overview -->
<ViewHeader title={famStore.fam?.name || 'Dashboard'} subtitle={summary?.weekStart ? `Week of ${summary.weekStart}` : ''} />
{#if toast}
<div class="toast">{toast}</div>
{/if}
<CardGrid>
<Card cols={1} title="Members">
{#each parentMembers as m}
{@const total = totalChoresFor(m.id)}
{@const todays = todayCompletionsFor(m.id)}
{@const done = todays.length}
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
{@const s = memberInSummary(m.id)}
<div class="member-card">
<div class="card-header">
<span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span>
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
</div>
<div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span>
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
</div>
<div class="progress-row">
<span class="label">Today:</span>
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="count">{done}/{total}</span>
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} class="revoke-form">
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
</form>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
</Card>
<Card cols={1} title="Triggers">
{#if manualConfigs().length === 0}
<p class="empty">No manual bonus configs.</p>
{:else}
{#each manualConfigs() as bc}
{@const val = bc.rewardType === 'cash' ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue}
{@const targeted = bc.memberId ? parentMembers.find((m: any) => m.id === bc.memberId) : null}
<div class="trigger-card">
<div class="trigger-head">
<span class="trigger-name">{bc.name}</span>
<span class="trigger-value">{val}</span>
</div>
{#if targeted}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<input type="hidden" name="configId" value={bc.id} />
<input type="hidden" name="memberId" value={targeted.id} />
<div class="trigger-row">
<span class="trigger-target" style="color:{targeted.color}">{targeted.name}</span>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{:else}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<input type="hidden" name="configId" value={bc.id} />
<div class="trigger-row">
<select name="memberId" class="trigger-select">
<option value="">Select member</option>
{#each parentMembers as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{/if}
</div>
{/each}
{/if}
</Card>
<Card cols={1} title="Claims" accent="#f59e0b">
{#if claimableRewards().length === 0}
<p class="empty">No outstanding claims</p>
{:else}
{#each parentMembers as m}
{@const mc = memberClaimable(m.id)}
{#if mc.length > 0}
<div class="member-claims">
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
{#each mc as r}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<div class="payment-row">
<span>{r.label}</span>
<div class="payment-right">
<span class="value">{rewardLabel(r)}</span>
<Button type="submit" size="sm" variant="primary">Claim</Button>
</div>
</div>
</form>
{/each}
<div class="response-area">
<p class="respond-label">Response:</p>
<div class="tags">
{#each responseTags as tag}
<button
type="button"
class="tag"
class:selected={selectedMessage[m.id] === tag}
onclick={() => selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag}
>{tag}</button>
{/each}
</div>
<input
type="text"
class="custom-msg"
placeholder="Or type your own..."
bind:value={customMessage[m.id]}
oninput={() => { if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; }}
/>
</div>
</div>
{/if}
{/each}
{/if}
</Card>
</CardGrid>
{:else}
<!-- Child View: Member Kanban -->
<ViewHeader title={memberName || username} />
<CardGrid>
<Card cols={3}>
<h1>
<span class="color-dot" style="background:{memberColor}" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); showColorPicker = !showColorPicker }}
onkeydown={e => e.key === 'Enter' && (showColorPicker = !showColorPicker)}></span>
{#if editingName}
<input type="text" bind:value={nameInput} class="name-edit"
onkeydown={async (e: KeyboardEvent) => {
if (e.key === 'Enter') {
editingName = false;
if (nameInput && nameInput !== memberName) {
const old = memberName;
memberName = nameInput;
try {
const res = await fetch('/api/members/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
body: JSON.stringify({ name: nameInput }),
});
if (!res.ok) memberName = old;
} catch { memberName = old; }
}
} else if (e.key === 'Escape') {
editingName = false;
nameInput = memberName;
}
}}
onblur={() => { editingName = false; nameInput = memberName; }}
autofocus />
{:else}
<span class="name-display" role="button" tabindex="0"
onclick={() => { nameInput = memberName; editingName = true; }}
onkeydown={e => e.key === 'Enter' && (nameInput = memberName, editingName = true)}>
{memberName || username}
</span>
{/if}
</h1>
{#if showColorPicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="color-picker-overlay" onclick={() => showColorPicker = false}>
<div class="color-picker-popup" onclick={(e) => e.stopPropagation()}>
<p>Pick a color:</p>
<div class="color-swatches">
{#each ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899','#78716c'] as color}
<button class="swatch" style="background:{color}" aria-label={color}
onclick={async () => {
const old = memberColor;
memberColor = color;
showColorPicker = false;
try {
const res = await fetch('/api/members/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
body: JSON.stringify({ color }),
});
if (!res.ok) memberColor = old;
} catch { memberColor = old; }
}}></button>
{/each}
</div>
</div>
</div>
{/if}
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else}
<!-- Badge Cards -->
<div class="badges-row">
<div class="badge-card notice">
<span class="badge-label">Bonus Notices</span>
<span class="badge-value">{bonusNotices.length}</span>
{#if bonusNotices.length > 0}
<span class="badge-sub">+{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts</span>
{/if}
</div>
<div class="badge-card cash">
<span class="badge-label">Total Cash</span>
<span class="badge-value">£{(totalCash / 100).toFixed(2)}</span>
</div>
<div class="badge-card pending">
<span class="badge-label">Pending Cash</span>
<span class="badge-value">£{(pendingCash / 100).toFixed(2)}</span>
</div>
<div class="badge-card points">
<span class="badge-label">Total Points</span>
<span class="badge-value">{totalPoints}</span>
</div>
</div>
<!-- Weekly Progress -->
{#if weeklyTotal > 0}
{@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)}
<div class="week-card">
<div class="week-head">
<span class="week-label">This Week</span>
<span class="week-stat">{weeklyDone}/{weeklyTotal} done</span>
</div>
<div class="bar-wrap lg">
<div class="bar-fill" style="width:{wpct}%"></div>
</div>
</div>
{/if}
<!-- Bonus Progress -->
{#if bonusProgresses.length > 0}
<div class="bonus-grid">
{#each bonusProgresses as bp}
{@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0}
{@const hasReward = bp.periodStart ? rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id && r.date >= bp.periodStart && r.date <= bp.periodEnd) : rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id)}
{@const typeClass = bp.config.type === 'threshold' ? 'type-threshold' : bp.config.type === 'count' ? 'type-count' : 'type-manual'}
<div class="bonus-badge {typeClass}" class:achieved={hasReward}>
<div class="bonus-head">
<span class="bonus-name">{bp.config.name}</span>
<span class="bonus-type">{bp.config.type}</span>
</div>
<div class="bonus-body">
{#if bp.criteria > 0}
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="bonus-stat">{bp.current}/{bp.criteria} {bp.config.type === 'threshold' ? 'pts' : 'chores'}</span>
{/if}
</div>
{#if hasReward}
<span class="bonus-done">Reward earned 🎉</span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Kanban -->
<div class="kanban">
<div class="column">
<h2>Daily Pending ({dailyPending.length})</h2>
{#if dailyPending.length === 0}
<p class="empty">All done!</p>
{:else}
{#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{/if}
</div>
<div class="column">
<h2>Weekly Pending ({weeklyPending.length})</h2>
{#if weeklyPending.length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{/if}
</div>
<div class="column">
<h2>Completed Today ({completedToday.length})</h2>
{#if completedToday.length === 0}
<p class="empty">Nothing yet</p>
{:else}
{#each completedToday as c}
{@const chore = assigned.find(a => a.id === c.assignedChoreId)}
{#if chore}
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/if}
{/each}
{/if}
</div>
<div class="column claim-col">
<h2>Claimable ({claimableRewardsChild.length})</h2>
{#if claimableRewardsChild.length === 0}
<p class="empty">Nothing to claim</p>
{:else}
{#each claimableRewardsChild as r}
<div class="claim-card">
<div class="claim-body">
<span class="claim-label">{r.label}</span>
<span class="claim-value">{rewardLabelChild(r)}</span>
</div>
<div class="claim-actions">
<span class="badge">{r.rewardType}</span>
<button class="request-btn" onclick={async () => { try { await memberApi.claimReward(deviceToken, famId, r.id); requestedRewards = [...requestedRewards, r.id]; } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } }} disabled={requestedRewards.includes(r.id)}>
{requestedRewards.includes(r.id) ? 'Requested' : 'Request'}
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</Card>
</CardGrid>
{/if}
<style>
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; 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; }
@keyframes fadein { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1.5rem; }
.member-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: white; margin-bottom: 0.6rem; }
.member-card:last-child { margin-bottom: 0; }
.card-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.member-name { font-weight: 600; flex: 1; font-size: 0.9rem; }
.link { font-size: 0.8rem; color: #6366f1; text-decoration: none; }
.stats { display: flex; gap: 0.75rem; font-size: 0.8rem; color: #6b7280; margin-bottom: 0.4rem; }
.progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
.label { font-size: 0.75rem; color: #6b7280; min-width: 2.5rem; }
.bar-wrap { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: #6366f1; border-radius: 4px; transition: width 0.3s; }
.count { font-size: 0.75rem; color: #6b7280; min-width: 3rem; text-align: right; }
.done-list { list-style: none; padding: 0; margin: 0; }
.done-list li { font-size: 0.75rem; padding: 0.05rem 0; color: #059669; display: flex; align-items: center; gap: 0.25rem; }
.revoke-form { display: inline; }
.revoke-btn { background: none; border: 1px solid transparent; border-radius: 3px; cursor: pointer; padding: 0 2px; font-size: 0.75rem; line-height: 1.2; color: #9ca3af; }
.revoke-btn:hover { color: #dc2626; border-color: #fca5a5; background: #fef2f2; }
.trigger-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: #f0fdf4; margin-bottom: 0.6rem; border-left: 3px solid #22c55e; }
.trigger-card:last-child { margin-bottom: 0; }
.trigger-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
.trigger-name { font-weight: 600; font-size: 0.9rem; }
.trigger-value { font-size: 0.85rem; color: #059669; font-weight: 600; }
.trigger-row { display: flex; gap: 0.4rem; }
.trigger-select { flex: 1; padding: 0.35rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; }
.member-claims { margin-bottom: 0.75rem; }
.member-claims:last-child { margin-bottom: 0; }
.member-claims h4 { display: flex; align-items: center; gap: 0.3rem; margin: 0 0 0.2rem; font-size: 0.85rem; }
.payment-row { display: flex; justify-content: space-between; font-size: 0.8rem; padding: 0.1rem 0; }
.payment-row .value { font-weight: 600; color: #059669; }
.payment-right { display: flex; align-items: center; gap: 0.4rem; }
.respond-label { font-size: 0.8rem; color: #6b7280; margin: 0.4rem 0 0.3rem; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; border-radius: 999px; background: white; cursor: pointer; }
.tag:hover { background: #f3f4f6; }
.tag.selected { background: #6366f1; color: white; border-color: #6366f1; }
.custom-msg { margin-top: 0.3rem; padding: 0.3rem 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; width: 100%; box-sizing: border-box; }
.error { color: #dc2626; padding: 1rem; text-align: center; }
.badges-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
.badge-card { flex: 1; min-width: 120px; padding: 0.75rem; border-radius: 8px; background: #f9fafb; border: 1px solid #e5e7eb; }
.badge-label { font-size: 0.75rem; color: #6b7280; display: block; }
.badge-value { font-size: 1.4rem; font-weight: 700; display: block; }
.badge-sub { font-size: 0.75rem; color: #059669; }
.badge-card.notice { border-left: 3px solid #f59e0b; }
.badge-card.cash { border-left: 3px solid #10b981; }
.badge-card.pending { border-left: 3px solid #3b82f6; }
.badge-card.points { border-left: 3px solid #8b5cf6; }
.week-card { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; }
.week-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.week-label { font-weight: 700; font-size: 0.95rem; color: #166534; }
.week-stat { font-size: 0.8rem; color: #16a34a; font-weight: 600; }
.bar-wrap.lg { height: 14px; }
.bonus-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; margin-bottom: 1rem; }
.bonus-badge { border-radius: 10px; padding: 0.7rem; background: white; border: 1px solid #e5e7eb; border-left: 4px solid #9ca3af; }
.bonus-badge.type-threshold { border-left-color: #8b5cf6; }
.bonus-badge.type-count { border-left-color: #3b82f6; }
.bonus-badge.type-manual { border-left-color: #f59e0b; }
.bonus-badge.achieved { background: #f0fdf4; border-color: #bbf7d0; border-left-color: #22c55e; }
.bonus-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.3rem; }
.bonus-name { font-size: 0.8rem; font-weight: 600; }
.bonus-type { font-size: 0.6rem; padding: 1px 6px; border-radius: 6px; background: #f3f4f6; color: #6b7280; text-transform: capitalize; }
.bonus-body { display: flex; align-items: center; gap: 0.5rem; }
.bonus-body .bar-wrap { flex: 1; height: 8px; }
.bonus-stat { font-size: 0.7rem; color: #6b7280; white-space: nowrap; font-weight: 600; min-width: 70px; text-align: right; }
.bonus-done { font-size: 0.7rem; color: #16a34a; font-weight: 600; margin-top: 0.2rem; display: block; }
.kanban { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 0.75rem; margin-top: 0.5rem; }
.column { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.75rem; background: #f9fafb; }
.column h2 { margin: 0 0 0.75rem; font-size: 0.95rem; }
.chore { display: flex; align-items: center; gap: 0.4rem; width: 100%; padding: 0.5rem 0.6rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer; text-align: left; font-size: 0.85rem; }
.chore:hover { background: #f3f4f6; }
.chore:disabled { opacity: 0.5; cursor: not-allowed; }
.done { opacity: 0.65; }
.checkbox { font-size: 1rem; flex-shrink: 0; }
.chore-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chore-value { font-size: 0.75rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
.claim-col { border-left: 3px solid #10b981; }
.claim-card { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; }
.claim-body { flex: 1; }
.claim-label { font-size: 0.8rem; display: block; }
.claim-value { font-size: 0.9rem; font-weight: 700; color: #059669; }
.claim-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; }
.badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 8px; background: #d1fae5; color: #059669; white-space: nowrap; }
.request-btn { font-size: 0.7rem; padding: 2px 8px; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
.request-btn:disabled { background: #9ca3af; cursor: default; }
h1 { display: flex; align-items: center; gap: 0.5rem; }
.color-dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; flex-shrink: 0; }
.name-display { cursor: pointer; border-bottom: 1px dashed #d1d5db; }
.name-edit { font-size: 1.2rem; padding: 0.1rem 0.3rem; border: 1px solid #6366f1; border-radius: 4px; outline: none; font-weight: 700; }
.color-picker-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); }
.color-picker-popup { background: white; padding: 1.25rem; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); }
.color-picker-popup p { margin: 0 0 0.75rem; font-weight: 600; font-size: 0.9rem; }
.color-swatches { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.swatch { width: 36px; height: 36px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; }
.swatch:hover { border-color: #6366f1; transform: scale(1.1); }
</style>