bonuses: templates full-width list, drag-create modal, auto-evaluate, outstanding/completed states

This commit is contained in:
JCEEE
2026-07-30 15:27:03 +01:00
parent c55c090260
commit 8834281b4d
8 changed files with 524 additions and 676 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ export interface BonusProgress {
memberColor: string
current: number
criteriaValue: number
reward: { id: string; claimed: boolean } | null
reward: { id: string; status: string } | null
state: BonusState
achieved: boolean
}
@@ -95,24 +95,20 @@ export const actions = {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const templateId = fd.get('templateId') as string;
try {
const templates = await hono.admin.bonusConfigs(event, famId);
const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId);
if (!tmpl) return fail(400, { error: 'Template not found' });
const data: Record<string, unknown> = {
name: tmpl.name,
description: tmpl.description || '',
target: tmpl.target,
type: tmpl.type,
occurrence: tmpl.occurrence,
rewardType: tmpl.rewardType,
rewardValue: tmpl.rewardValue,
criteriaValue: tmpl.criteriaValue || 0,
period: tmpl.period || '',
memberId: tmpl.memberId || '',
phase: 'ready',
name: fd.get('name'),
description: fd.get('description') || '',
target: fd.get('target'),
type: fd.get('type'),
occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue'),
criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0,
period: fd.get('period') || '',
memberId: fd.get('memberId') || '',
phase: 'active',
};
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
@@ -17,7 +17,7 @@ import type {
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingConfig = $state<BonusConfig | null>(null);
let editFromTarget = $state<'member' | 'competition' | 'collaboration' | ''>('');
let creatingFromTemplate = $state<BonusConfig | null>(null);
let createVals = $state({
name: '',
@@ -53,20 +53,28 @@ import type {
let showPeriod = $derived(editVals.occurrence !== 'once');
let showCriteria = $derived(editVals.type !== 'manual');
let templateConfigs = $derived(configs.filter((c) => c.phase === 'template' || c.phase === 'active') as BonusConfig[]);
let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]);
let templateConfigs = $derived(configs.filter((c) => c.phase === 'template') 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.status === 'claimed') as Reward[]);
let unclaimedRewards = $derived(
allRewards.filter((r) => r.status === 'unclaimed') as Reward[]
);
let progressByConfigId = $derived.by(() => {
const map = new Map<string, BonusConfigWithProgress>();
for (const p of progressData) map.set(p.config.id, p);
return map;
});
$effect(() => {
const len = famStore.completions.length;
if (len > 0 && famStore.initialized) {
const famId = page.params.fam;
const username = page.params.username;
fetch(`/${famId}/${username}/bonuses?/evaluate`, { method: 'POST' })
.then(() => fetch(`/${famId}/${username}/bonuses/progress.json`))
.then((r) => r.ok && r.json())
.then((d) => { if (d) progressData = d; })
.catch(() => {});
}
});
function memberName(memberId: string): string {
return (
famStore.memberMap().get(memberId)?.name ||
@@ -75,15 +83,6 @@ import type {
);
}
function configName(bonusConfigId: string | undefined): string {
if (!bonusConfigId) return 'Manual';
return (
famStore.bonusConfigMap().get(bonusConfigId)?.name ||
configs.find((c) => c.id === bonusConfigId)?.name ||
'?'
);
}
function badgeClass(cfg: BonusConfig): string {
const classes: string[] = [];
if (cfg.target === 'individual') classes.push('indiv');
@@ -108,7 +107,7 @@ import type {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function openDropEdit(
function openCreateFromTemplate(
raw: string,
targetType: string,
source: 'member' | 'competition' | 'collaboration'
@@ -117,7 +116,7 @@ import type {
if (target === targetType) return;
const cfg = configs.find((c) => c.id === id);
if (!cfg) return;
editingConfig = cfg;
creatingFromTemplate = cfg;
editFromTarget = source;
editVals = {
name: cfg.name,
@@ -137,37 +136,19 @@ import type {
async function handleDropToMember(e: DragEvent) {
e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'individual', 'member');
if (raw) openCreateFromTemplate(raw, 'individual', 'member');
}
async function handleDropToCompetition(e: DragEvent) {
e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'competitive', 'competition');
if (raw) openCreateFromTemplate(raw, 'competitive', 'competition');
}
async function handleDropToCollaboration(e: DragEvent) {
e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'collaborative', 'collaboration');
}
function openEdit(cfg: BonusConfig) {
editingConfig = cfg;
editFromTarget = '';
editVals = {
name: cfg.name,
description: cfg.description || '',
target: cfg.target,
memberId: cfg.memberId || '',
type: cfg.type,
occurrence: cfg.occurrence,
rewardType: cfg.rewardType,
rewardValue: cfg.rewardValue,
criteriaValue: cfg.criteriaValue || 10,
period: cfg.period || 'weekly'
};
showEditModal = true;
if (raw) openCreateFromTemplate(raw, 'collaborative', 'collaboration');
}
function handleCreate() {
@@ -176,9 +157,6 @@ import type {
const rec = result.data?.record;
if (rec) {
configs = [rec as BonusConfig, ...configs];
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('bonus_configs', rec, 'create');
}
}
showCreateModal = false;
}
@@ -187,14 +165,9 @@ import type {
function handleUpdate() {
return async ({ result }: any) => {
if (result.type === 'success' && editingConfig) {
const updated = { ...editingConfig, ...editVals, rewardValue: editVals.rewardValue };
const idx = configs.findIndex((c) => c.id === editingConfig!.id);
if (idx !== -1) configs[idx] = updated as BonusConfig;
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('bonus_configs', updated, 'update');
}
if (result.type === 'success') {
showEditModal = false;
creatingFromTemplate = null;
editingConfig = null;
}
};
@@ -207,17 +180,26 @@ import type {
fire();
}
const showPeriodForCreate = $derived(createVals.occurrence !== 'once');
const showCriteriaForCreate = $derived(createVals.type !== 'manual');
async function evaluateAll() {
const fd = new FormData();
fd.set('configId', '');
const res = await fetch(`/${page.params.fam}/${page.params.username}/bonuses?/evaluate`, { method: 'POST', body: fd });
const data = await res.json();
if (!data?.error) fire();
let outstandingConfigs = $derived(activeConfigs.filter((c) => isOutstanding(c)));
let doneConfigs = $derived(activeConfigs.filter((c) => isCompleted(c) && !isOutstanding(c)));
function isCompleted(cfg: BonusConfig): boolean {
const prog = progressByConfigId.get(cfg.id);
if (cfg.occurrence === 'once') {
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
return rewards.length > 0;
}
if (!prog) return false;
return prog.progress.every((p) => p.achieved);
}
function isOutstanding(cfg: BonusConfig): boolean {
if (!isCompleted(cfg)) return false;
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
return rewards.some((r) => r.status === 'unclaimed');
}
async function fire() {
@@ -230,56 +212,12 @@ import type {
<CardGrid>
<Card cols={3}>
<div class="action-bar">
<Button onclick={evaluateAll} size="sm">Evaluate All</Button>
</div>
<div class="kanban">
<!-- Templates Column -->
<div class="column templates-col">
<div class="template-section">
<h3>Templates</h3>
<!-- Ready section (above CTA) -->
{#if readyConfigs.length}
{#each readyConfigs as cfg}
<div class="card ready">
<div class="card-head">
<strong>{cfg.name}</strong>
<form method="POST" action="?/deleteConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<button type="submit" class="del-btn" title="Remove">×</button>
</form>
</div>
<div class="badges">
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
<span class="badge">{cfg.type}</span>
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
<form method="POST" action="?/assignConfig" use:enhance class="assign-form">
<input type="hidden" name="id" value={cfg.id} />
<select name="target" class="asm-select">
<option value="individual">Individual</option>
<option value="competitive">Competitive</option>
<option value="collaborative">Collaborative</option>
</select>
<select name="memberId" class="asm-select">
<option value="">All</option>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
<button type="submit" class="assign-btn">Assign</button>
</form>
</div>
{/each}
{/if}
<Button onclick={() => (showCreateModal = true)}>+ New Bonus</Button>
<div class="template-list">
{#each templateConfigs as cfg}
<div
class="card"
class="template-card"
draggable="true"
ondragstart={(e) => handleDragStart(e, cfg)}
>
@@ -297,16 +235,23 @@ import type {
{#if cfg.period}<span class="badge period">{cfg.period}</span>{/if}
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
</div>
{/each}
{#if templateConfigs.length === 0}
<p class="empty">No templates yet. Create one below.</p>
{/if}
</div>
<Button onclick={() => (showCreateModal = true)}>+ New Template</Button>
</div>
<div class="kanban">
<!-- Member Column -->
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
<h3>Member</h3>
{#each activeConfigs.filter((c) => c.target === 'individual') as cfg}
<div class="col-card">
{@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
<div class="col-card" class:done class:outstanding>
<div class="col-card-head">
<strong>{cfg.name}</strong>
<div class="badges">
@@ -321,6 +266,7 @@ import type {
{#if p}
<div class="member-progress">
<span class="mname" style="color:{p.memberColor}">{p.memberName}</span>
{#if p.criteriaValue > 0}
<div class="bar-wrap">
<div
class="bar-fill"
@@ -328,13 +274,18 @@ import type {
class:met={p.achieved}
></div>
</div>
<span class="stat {p.state}">{p.state}</span>
{/if}
<span class="stat {p.state}">
{outstanding ? 'Outstanding' : p.state}
</span>
</div>
{/if}
{/if}
{/each}
<div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
{#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div>
</div>
{/each}
@@ -347,8 +298,10 @@ import type {
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}>
<h3>Competition</h3>
{#each activeConfigs.filter((c) => c.target === 'competitive') as cfg}
{@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
{@const prog = progressByConfigId.get(cfg.id)}
<div class="col-card">
<div class="col-card" class:done class:outstanding>
<div class="col-card-head">
<strong>{cfg.name}</strong>
<div class="badges">
@@ -363,6 +316,7 @@ import type {
<div class="lb-row" class:winner={i === 0 && p.current > 0}>
<span class="lb-rank">#{i + 1}</span>
<span class="mname" style="color:{p.memberColor}">{p.memberName}</span>
{#if p.criteriaValue > 0}
<div class="bar-wrap sm">
<div
class="bar-fill"
@@ -370,13 +324,18 @@ import type {
class:met={p.achieved}
></div>
</div>
<span class="stat {p.state}">{p.state}</span>
{/if}
<span class="stat {p.state}">
{outstanding ? 'Outstanding' : p.state}
</span>
</div>
{/each}
</div>
{/if}
<div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
{#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div>
</div>
{/each}
@@ -389,8 +348,10 @@ import type {
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
<h3>Collaboration</h3>
{#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg}
{@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
{@const prog = progressByConfigId.get(cfg.id)}
<div class="col-card">
<div class="col-card" class:done class:outstanding>
<div class="col-card-head">
<strong>{cfg.name}</strong>
<div class="badges">
@@ -404,6 +365,7 @@ import type {
{#if team}
<div class="team-progress">
<span class="mname" style="color:{team.memberColor}">{team.memberName}</span>
{#if team.criteriaValue > 0}
<div class="bar-wrap">
<div
class="bar-fill"
@@ -414,16 +376,20 @@ import type {
class:met={team.achieved}
></div>
</div>
<span class="stat"
>{team.current}/{team.criteriaValue}
{cfg.type === 'threshold' ? 'pts' : 'chores'}</span
>
<span class="stat {team.state}">{team.state}</span>
{/if}
<span class="stat">
{team.current}/{team.criteriaValue}
{cfg.type === 'threshold' ? 'pts' : 'chores'}</span>
<span class="stat {team.state}">
{outstanding ? 'Outstanding' : team.state}
</span>
</div>
{/if}
{/if}
<div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
{#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div>
</div>
{/each}
@@ -433,33 +399,41 @@ import type {
</div>
</div>
<!-- Completed Section -->
{#if completedConfigs.length}
<!-- Outstanding section (completed configs with unclaimed rewards) -->
{#if outstandingConfigs.length > 0}
<div class="outstanding-section">
<h3>Outstanding — Issue to complete</h3>
<div class="outstanding-grid">
{#each outstandingConfigs as cfg}
{@const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed')}
<div class="outstanding-card">
<div class="card-head">
<strong>{cfg.name}</strong>
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
{#each rewards as r}
<div class="claim-row">
<span>{memberName(r.memberId)}</span>
<button onclick={() => claimReward(r.id)} class="claim-btn">Issue</button>
</div>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<!-- Completed section (all rewards claimed) -->
{#if doneConfigs.length > 0}
<div class="completed-section">
<h3>Completed</h3>
<div class="completed-grid">
{#each completedConfigs as cfg}
{#each doneConfigs as cfg}
<div class="completed-card">
<div class="card-head">
<strong>{cfg.name}</strong>
<div class="badges">
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
<span class="badge">{cfg.type}</span>
</div>
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
<div class="card-actions">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
<form method="POST" action="?/completeConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<input type="hidden" name="phase" value="active" />
<button type="submit" class="reactivate-btn">Reactivate</button>
</form>
<form method="POST" action="?/destroyConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<button type="submit" class="del-btn" title="Permanently delete">×</button>
</form>
</div>
</div>
{/each}
</div>
@@ -470,13 +444,12 @@ import type {
{#if showCreateModal}
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Bonus</h3>
<h3>New Bonus Template</h3>
<form method="POST" action="?/createConfig" use:enhance={handleCreate}>
<input type="hidden" name="phase" value="template" />
<label>Name <input name="name" bind:value={createVals.name} required /></label>
<label>Description <input name="description" bind:value={createVals.description} /></label>
<label
>Target
<label>Target
<select name="target" bind:value={createVals.target}>
<option value="individual">Individual</option>
<option value="competitive">Competitive</option>
@@ -484,8 +457,7 @@ import type {
</select>
</label>
{#if createVals.target === 'individual'}
<label
>Member
<label>Member
<select name="memberId" bind:value={createVals.memberId}>
<option value="">All (everyone)</option>
{#each members as m}
@@ -494,8 +466,7 @@ import type {
</select>
</label>
{/if}
<label
>Type
<label>Type
<select name="type" bind:value={createVals.type}>
<option value="threshold">Threshold (points)</option>
<option value="count">Count (completions)</option>
@@ -503,31 +474,19 @@ import type {
</select>
</label>
{#if showCriteriaForCreate}
<label
>Criteria Value
<input
type="number"
name="criteriaValue"
bind:value={createVals.criteriaValue}
required
/>
<span class="hint"
>{createVals.type === 'threshold'
? 'Points needed'
: 'Chore completions needed'}</span
>
<label>Criteria Value
<input type="number" name="criteriaValue" bind:value={createVals.criteriaValue} required />
<span class="hint">{createVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span>
</label>
{/if}
<label
>Occurrence
<label>Occurrence
<select name="occurrence" bind:value={createVals.occurrence}>
<option value="recurring">Recurring</option>
<option value="once">Once</option>
</select>
</label>
{#if showPeriodForCreate}
<label
>Period
<label>Period
<select name="period" bind:value={createVals.period}>
<option value="">Anytime</option>
<option value="weekly">Weekly</option>
@@ -535,24 +494,16 @@ import type {
</select>
</label>
{/if}
<label
>Reward Type
<label>Reward Type
<select name="rewardType" bind:value={createVals.rewardType}>
<option value="points">Points</option>
<option value="cash">Cash</option>
<option value="prize">Prize</option>
</select>
</label>
<label
>Reward Value
<label>Reward Value
<input name="rewardValue" bind:value={createVals.rewardValue} required />
<span class="hint"
>{createVals.rewardType === 'cash'
? 'Amount in pence (e.g. 200 = £2)'
: createVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
<span class="hint">{createVals.rewardType === 'cash' ? 'Amount in pence (e.g. 200 = £2)' : createVals.rewardType === 'points' ? 'Points to award' : 'Prize description'}</span>
</label>
<div class="modal-actions">
<button type="button" onclick={() => (showCreateModal = false)}>Cancel</button>
@@ -563,25 +514,19 @@ import type {
</div>
{/if}
<!-- Edit Modal -->
{#if showEditModal && editingConfig}
<div
class="overlay"
onclick={() => {
showEditModal = false;
editingConfig = null;
}}
role="presentation"
>
<!-- Edit / Create-from-Template Modal -->
{#if showEditModal && (editingConfig || creatingFromTemplate)}
{@const targetCfg = creatingFromTemplate || editingConfig!}
<div class="overlay" onclick={() => { showEditModal = false; creatingFromTemplate = null; editingConfig = null; }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit: {editingConfig.name}</h3>
<form method="POST" action="?/updateConfig" use:enhance={handleUpdate}>
<h3>{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}</h3>
<form method="POST" action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'} use:enhance={handleUpdate}>
{#if editingConfig}
<input type="hidden" name="id" value={editingConfig.id} />
<input type="hidden" name="templateId" value={editingConfig.id} />
{/if}
<label>Name <input name="name" bind:value={editVals.name} required /></label>
<label>Description <input name="description" bind:value={editVals.description} /></label>
<label
>Target
<label>Target
<select name="target" bind:value={editVals.target}>
<option value="individual">Individual</option>
<option value="competitive">Competitive</option>
@@ -589,8 +534,7 @@ import type {
</select>
</label>
{#if editVals.target === 'individual'}
<label
>Member
<label>Member
<select name="memberId" bind:value={editVals.memberId}>
<option value="">All (everyone)</option>
{#each members as m}
@@ -599,8 +543,7 @@ import type {
</select>
</label>
{/if}
<label
>Type
<label>Type
<select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold (points)</option>
<option value="count">Count (completions)</option>
@@ -608,29 +551,19 @@ import type {
</select>
</label>
{#if showCriteria}
<label
>Criteria Value
<input
type="number"
name="criteriaValue"
bind:value={editVals.criteriaValue}
required
/>
<span class="hint"
>{editVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span
>
<label>Criteria Value
<input type="number" name="criteriaValue" bind:value={editVals.criteriaValue} required />
<span class="hint">{editVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span>
</label>
{/if}
<label
>Occurrence
<label>Occurrence
<select name="occurrence" bind:value={editVals.occurrence}>
<option value="recurring">Recurring</option>
<option value="once">Once</option>
</select>
</label>
{#if showPeriod}
<label
>Period
<label>Period
<select name="period" bind:value={editVals.period}>
<option value="">Anytime</option>
<option value="weekly">Weekly</option>
@@ -638,43 +571,20 @@ import type {
</select>
</label>
{/if}
<label
>Reward Type
<label>Reward Type
<select name="rewardType" bind:value={editVals.rewardType}>
<option value="points">Points</option>
<option value="cash">Cash</option>
<option value="prize">Prize</option>
</select>
</label>
<label
>Reward Value
<label>Reward Value
<input name="rewardValue" bind:value={editVals.rewardValue} required />
<span class="hint"
>{editVals.rewardType === 'cash'
? 'Amount in pence'
: editVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
<span class="hint">{editVals.rewardType === 'cash' ? 'Amount in pence' : editVals.rewardType === 'points' ? 'Points to award' : 'Prize description'}</span>
</label>
<div class="modal-actions">
<button
type="button"
onclick={() => {
showEditModal = false;
editingConfig = null;
}}>Cancel</button
>
<button type="submit">Save</button>
{#if editingConfig.phase === 'template'}
<button type="submit" formaction="?/createFromTemplate" class="create-from-btn">Create Instance</button>
{:else if editingConfig.phase === 'ready' || editingConfig.phase === 'active'}
<input type="hidden" name="phase" value="completed" />
<button type="submit" formaction="?/completeConfig" class="complete-btn">Mark Complete</button>
{:else if editingConfig.phase === 'completed'}
<input type="hidden" name="phase" value="active" />
<button type="submit" formaction="?/completeConfig" class="reactivate-btn">Reactivate</button>
{/if}
<button type="button" onclick={() => { showEditModal = false; creatingFromTemplate = null; editingConfig = null; }}>Discard</button>
<button type="submit">{creatingFromTemplate ? 'Confirm' : 'Save'}</button>
</div>
</form>
</div>
@@ -684,6 +594,31 @@ import type {
</CardGrid>
<style>
.template-section {
margin-bottom: 1.5rem;
}
.template-section h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.template-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.template-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.5rem 0.75rem;
min-width: 160px;
cursor: grab;
}
.template-card:active {
cursor: grabbing;
opacity: 0.8;
}
.kanban {
display: flex;
gap: 1rem;
@@ -705,43 +640,6 @@ import type {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.action-bar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.card-actions { display: flex; gap: 0.3rem; margin-top: 0.3rem; }
.add-btn {
width: 100%;
padding: 0.5rem;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.empty {
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 6px;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
cursor: grab;
}
.card:active {
cursor: grabbing;
opacity: 0.8;
}
.card.archived {
opacity: 0.5;
}
.card-head {
display: flex;
justify-content: space-between;
@@ -772,7 +670,6 @@ import type {
background: #fce7f3;
color: #be185d;
}
.badge.period {
background: #d1fae5;
color: #059669;
@@ -786,15 +683,6 @@ import type {
color: #6b7280;
font-weight: 600;
}
.edit-btn {
font-size: 0.7rem;
padding: 2px 8px;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
margin-top: 0.3rem;
}
.del-btn {
background: none;
border: none;
@@ -807,12 +695,14 @@ import type {
.del-btn:hover {
color: #dc2626;
}
.archived-hdr {
font-size: 0.8rem;
.empty {
color: #9ca3af;
margin: 0.75rem 0 0.25rem;
font-size: 0.85rem;
text-align: center;
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 6px;
}
.col-card {
background: white;
border: 1px solid #e5e7eb;
@@ -823,6 +713,10 @@ import type {
.col-card.done {
opacity: 0.75;
}
.col-card.outstanding {
border-color: #f59e0b;
background: #fffbeb;
}
.col-card-head {
display: flex;
justify-content: space-between;
@@ -837,7 +731,6 @@ import type {
display: flex;
gap: 0.3rem;
}
.member-progress {
display: flex;
align-items: center;
@@ -888,29 +781,6 @@ import type {
background: #d1fae5;
color: #059669;
}
.trigger-btn,
.claim-btn,
.eval-btn {
font-size: 0.65rem;
padding: 2px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.trigger-btn {
background: #f59e0b;
color: white;
}
.claim-btn {
background: #10b981;
color: white;
}
.eval-btn {
background: #6366f1;
color: white;
}
.leaderboard {
margin-bottom: 0.3rem;
}
@@ -931,20 +801,15 @@ import type {
color: #9ca3af;
min-width: 20px;
}
.done-body {
font-size: 0.8rem;
color: #6b7280;
.claim-btn {
background: #10b981;
color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.65rem;
cursor: pointer;
}
.done-body .reward-val {
font-weight: 600;
color: #059669;
display: block;
}
.ts {
font-size: 0.7rem;
color: #9ca3af;
}
.overlay {
position: fixed;
inset: 0;
@@ -1011,44 +876,35 @@ import type {
background: #6366f1;
color: white;
}
/* ── Lifecycle styles ── */
.card.ready {
border-color: #f59e0b;
background: #fffbeb;
cursor: default;
.outstanding-section {
margin-top: 2rem;
border-top: 2px solid #f59e0b;
padding-top: 1rem;
}
.assign-form {
.outstanding-section h3 {
font-size: 1rem;
margin: 0 0 0.75rem;
color: #b45309;
}
.outstanding-grid {
display: flex;
gap: 4px;
margin-top: 0.4rem;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.asm-select {
font-size: 0.65rem;
padding: 2px 4px;
border: 1px solid #d1d5db;
border-radius: 4px;
flex: 1;
min-width: 70px;
.outstanding-card {
background: #fffbeb;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 0.5rem 0.75rem;
min-width: 180px;
}
.assign-btn,
.create-from-btn,
.complete-btn,
.reactivate-btn {
font-size: 0.65rem;
padding: 2px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
.claim-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
margin-top: 0.3rem;
}
.assign-btn { background: #f59e0b; color: white; }
.create-from-btn { background: #6366f1; color: white; }
.complete-btn { background: #10b981; color: white; }
.reactivate-btn { background: #8b5cf6; color: white; }
.completed-section {
margin-top: 2rem;
border-top: 2px solid #e5e7eb;
@@ -0,0 +1,12 @@
import { hono } from '$lib/server/hono';
import { json } from '@sveltejs/kit';
export async function GET(event) {
const famId = event.params.fam;
try {
const progress = await hono.admin.bonusConfigProgress(event, famId);
return json(progress);
} catch {
return json([]);
}
}
@@ -134,15 +134,12 @@
}
function enhanceCreate() {
return async ({ result, formData }: any) => {
return async ({ result }: any) => {
if (result.type === 'success') {
showCreateModal = false
const rec = result.data?.record
if (rec) {
templates = [rec as ChoreTemplate, ...templates]
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('chore_templates', rec, 'create')
}
}
}
}
@@ -161,9 +158,6 @@
customName: editCustomName || undefined,
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
} as AssignedChore
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
}
}
}
closeEdit()
@@ -183,19 +177,16 @@
defaultType: editTplType,
defaultValue: editTplValue,
} as ChoreTemplate
}
assigned = assigned.map((a: any) =>
a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a
) as AssignedChore[]
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('chore_templates', templates[idx], 'update')
}
closeEditTemplate()
}
}
}
}
</script>
@@ -239,7 +230,7 @@
</div>
<div class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"></button>
<form method="POST" action="?/deleteTemplate" use:enhance={() => { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; if (famStore.fam?.featureFlags?.optimisticUpdates) { const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } } }; }}>
<form method="POST" action="?/deleteTemplate" use:enhance={() => { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; } }; }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" class="del-btn" title="Delete template">×</button>
</form>
@@ -98,7 +98,7 @@
</td>
<td>
{#if outstanding}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record && famStore.fam?.featureFlags?.optimisticUpdates) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}>
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { fire(); } }; }}>
<input name="id" type="hidden" value={r.id} />
<Button type="submit" size="sm">Claim</Button>
</form>
@@ -169,11 +169,6 @@ import { onDestroy } from 'svelte';
<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;
}
}}}>
+4 -6
View File
@@ -454,7 +454,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
}
}
const summaries = members.items.map((m: any) => {
const summaries = await Promise.all(members.items.map(async (m: any) => {
const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id);
const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id);
@@ -505,11 +505,9 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
dayPoints,
dayCompletions,
};
});
}));
return c.json({ weekStart: ws, daysInWeek, summaries });
return c.json({ weekStart: ws, summaries });
} catch (err) { return handleError(c, err); }
});
@@ -749,7 +747,7 @@ async function evaluateFam(famId: string): Promise<void> {
famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
claimed: cfg.rewardType === "points",
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
});
@@ -807,7 +805,7 @@ async function evaluateFam(famId: string): Promise<void> {
: `${cfg.name} ${cfg.rewardValue}`;
const now = new Date().toISOString();
await pb.create("rewards", {
famId, memberId: m.id, bonusConfigId: cfg.id,
famId, memberId: winner.memberId, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",