1595 lines
40 KiB
Svelte
1595 lines
40 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.status === 'unclaimed' || r.status === 'requested');
|
|
}
|
|
|
|
function memberClaimable(memberId: string) {
|
|
return claimableRewards().filter((r: any) => r.memberId === memberId);
|
|
}
|
|
|
|
function memberRequested(memberId: string) {
|
|
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'requested');
|
|
}
|
|
|
|
function memberOutstanding(memberId: string) {
|
|
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'unclaimed');
|
|
}
|
|
|
|
function memberRequestedTotal(memberId: string) {
|
|
return memberRequested(memberId)
|
|
.filter((r: any) => r.rewardType === 'cash')
|
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
}
|
|
|
|
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).toFixed(2)}`;
|
|
if (r.rewardType === 'points') return `${r.value} pts`;
|
|
return r.label;
|
|
}
|
|
|
|
function manualConfigs() {
|
|
return parentBonusConfigs.filter((b: any) => {
|
|
if (!(b.status === 'active' && b.type === 'manual')) return false;
|
|
if (b.occurrence === 'once') {
|
|
const hasReward = parentRewards.some(
|
|
(r: any) => r.bonusConfigId === b.id && (!b.memberId || r.memberId === b.memberId)
|
|
);
|
|
if (hasReward) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// ─── 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 = $derived(
|
|
famStore.initialized
|
|
? (famStore.templates as ChoreTemplate[])
|
|
: (data.templates as ChoreTemplate[])
|
|
);
|
|
let assigned = $derived(
|
|
famStore.initialized
|
|
? (famStore.assigned as AssignedChore[])
|
|
: (data.assigned as AssignedChore[])
|
|
);
|
|
let completions = $derived(
|
|
famStore.initialized
|
|
? (famStore.completions as Completion[])
|
|
: (data.completions as Completion[])
|
|
);
|
|
let rewards = $derived(
|
|
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])
|
|
);
|
|
let bonusConfigs = $derived(
|
|
famStore.initialized
|
|
? (famStore.bonusConfigs as BonusConfig[])
|
|
: (data.bonusConfigs as BonusConfig[])
|
|
);
|
|
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
|
|
let requestedRewards = $state<string[]>([]);
|
|
let claimError = $state('');
|
|
let todayChild = $state(new Date().toISOString().slice(0, 10));
|
|
let togglingIds = $state<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`;
|
|
}
|
|
|
|
let memberChores = $derived.by(() => {
|
|
if (role === 'parent' || !memberId) return [];
|
|
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
|
|
return assigned.filter((a) => {
|
|
if (a.memberId !== memberId) return false;
|
|
if (!a.seasonIds || a.seasonIds.length === 0) return true;
|
|
if (activeSeasonIds.size === 0) return true;
|
|
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
|
|
});
|
|
});
|
|
|
|
let dailyPending = $derived(
|
|
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
|
|
);
|
|
let weeklyPending = $derived(
|
|
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
|
|
);
|
|
let completedToday = $derived(
|
|
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
|
|
);
|
|
let claimableRewardsChild = $derived(
|
|
rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed')
|
|
);
|
|
let requestedRewardsChild = $derived(
|
|
rewards.filter((r) => r.memberId === memberId && r.status === 'requested')
|
|
);
|
|
|
|
let weeklyTotal = $derived.by(() => {
|
|
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
|
|
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
|
|
return dailyChores.length * 7 + weeklyChores.length;
|
|
});
|
|
|
|
let weeklyDone = $derived.by(() => {
|
|
const ws = weekStartStr();
|
|
return completions.filter(
|
|
(c) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
|
|
).length;
|
|
});
|
|
|
|
let totalCash = $derived.by(() => {
|
|
const myCompletions = completions.filter((c) => c.memberId === memberId);
|
|
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
|
return (
|
|
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)
|
|
);
|
|
});
|
|
|
|
let pendingCash = $derived.by(() => {
|
|
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
|
return myRewards
|
|
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
|
|
.reduce((sum, r) => sum + Number(r.value), 0);
|
|
});
|
|
|
|
let totalPoints = $derived.by(() => {
|
|
const myCompletions = completions.filter((c) => c.memberId === memberId);
|
|
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
|
return (
|
|
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)
|
|
);
|
|
});
|
|
|
|
let bonusNotices = $derived.by(() => {
|
|
const myRewards = rewards.filter((r) => r.memberId === memberId);
|
|
return myRewards.filter((r) => r.status === 'claimed' && r.claimedAt?.slice(0, 10) === r.date);
|
|
});
|
|
|
|
let bonusProgresses = $derived.by(() => {
|
|
const ws = weekStartStr();
|
|
const myCompletions = completions.filter((c) => c.memberId === memberId);
|
|
const activeConfigs = bonusConfigs.filter(
|
|
(b: BonusConfig) =>
|
|
b.status === 'active' &&
|
|
b.target === 'individual' &&
|
|
(!b.memberId || b.memberId === memberId)
|
|
);
|
|
return 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
|
|
};
|
|
});
|
|
});
|
|
|
|
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 {}
|
|
}
|
|
|
|
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) {
|
|
famStore.applyRecord('completions', existing, 'delete');
|
|
}
|
|
} else {
|
|
famStore.applyRecord(
|
|
'completions',
|
|
{
|
|
id: 'optimistic-' + chore.id,
|
|
famId,
|
|
memberId,
|
|
assignedChoreId: chore.id,
|
|
date: todayChild,
|
|
completedAt: new Date().toISOString()
|
|
} as Completion,
|
|
'create'
|
|
);
|
|
}
|
|
|
|
try {
|
|
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
|
|
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
|
if (optimistic) {
|
|
famStore.applyRecord('completions', optimistic, 'delete');
|
|
}
|
|
} catch (e) {
|
|
if (wasCompleted) {
|
|
famStore.applyRecord(
|
|
'completions',
|
|
{
|
|
id: 'revert-' + chore.id + '-' + Date.now(),
|
|
famId,
|
|
memberId,
|
|
assignedChoreId: chore.id,
|
|
date: todayChild,
|
|
completedAt: new Date().toISOString()
|
|
} as Completion,
|
|
'create'
|
|
);
|
|
} else {
|
|
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
|
if (optimistic) {
|
|
famStore.applyRecord('completions', optimistic, 'delete');
|
|
}
|
|
}
|
|
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).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);
|
|
}}
|
|
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).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);
|
|
}}
|
|
>
|
|
<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);
|
|
}}
|
|
>
|
|
<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 requested = memberRequested(m.id)}
|
|
{@const outstanding = memberOutstanding(m.id)}
|
|
{@const total = memberRequestedTotal(m.id)}
|
|
{#if requested.length > 0 || outstanding.length > 0}
|
|
<div class="member-claims">
|
|
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
|
{#if total > 0}
|
|
<p class="member-total">£{total.toFixed(2)} requested</p>
|
|
{/if}
|
|
|
|
{#if requested.length > 0}
|
|
<p class="section-label">Requested</p>
|
|
{#each requested as r}
|
|
<form
|
|
method="POST"
|
|
action="?/claim"
|
|
use:enhance={() => {
|
|
return async (args: any) => handleResult(args);
|
|
}}
|
|
>
|
|
<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">Issue</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{/each}
|
|
{#if requested.length > 1}
|
|
<form
|
|
method="POST"
|
|
action="?/issueAll"
|
|
use:enhance={() => {
|
|
return async (args: any) => handleResult(args);
|
|
}}
|
|
>
|
|
<input type="hidden" name="memberId" value={m.id} />
|
|
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
|
<Button type="submit" size="sm" variant="secondary"
|
|
>Issue All ({requested.length})</Button
|
|
>
|
|
</form>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if outstanding.length > 0}
|
|
<p class="section-label">Outstanding</p>
|
|
{#each outstanding as r}
|
|
<div class="payment-row outstanding">
|
|
<span>{r.label}</span>
|
|
<span class="value">{rewardLabel(r)}</span>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
<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.toFixed(2)}</span>
|
|
</div>
|
|
<div class="badge-card pending">
|
|
<span class="badge-label">Pending Cash</span>
|
|
<span class="badge-value">£{pendingCash.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}
|
|
{#if claimableRewardsChild.length > 1}
|
|
<button
|
|
class="request-all-btn"
|
|
onclick={async () => {
|
|
try {
|
|
await memberApi.requestAllRewards(deviceToken, famId);
|
|
} catch (e) {
|
|
claimError = e instanceof Error ? e.message : 'Request all failed';
|
|
}
|
|
}}
|
|
>
|
|
Request All
|
|
</button>
|
|
{/if}
|
|
{#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);
|
|
} catch (e) {
|
|
claimError = e instanceof Error ? e.message : 'Claim failed';
|
|
}
|
|
}}
|
|
>
|
|
Request
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="column request-col">
|
|
<h2>Requested ({requestedRewardsChild.length})</h2>
|
|
{#if requestedRewardsChild.length === 0}
|
|
<p class="empty">No pending requests</p>
|
|
{:else}
|
|
{#each requestedRewardsChild as r}
|
|
<div class="claim-card requested">
|
|
<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>
|
|
<span class="requested-badge">Requested</span>
|
|
</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 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;
|
|
}
|
|
.request-all-btn {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #2563eb;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.request-all-btn:hover {
|
|
background: #1d4ed8;
|
|
}
|
|
.request-col {
|
|
border-color: #93c5fd;
|
|
background: #eff6ff;
|
|
}
|
|
.claim-card.requested {
|
|
opacity: 0.7;
|
|
}
|
|
.requested-badge {
|
|
font-size: 0.7rem;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
font-weight: 600;
|
|
}
|
|
.section-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #6b7280;
|
|
margin: 0.5rem 0 0.25rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.member-total {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #2563eb;
|
|
margin: 0 0 0.25rem;
|
|
}
|
|
.payment-row.outstanding {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
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>
|