make major bonus updates

This commit is contained in:
JCEEE
2026-07-31 16:13:05 +01:00
parent 8834281b4d
commit d720f4e863
12 changed files with 4174 additions and 2598 deletions
+112 -50
View File
@@ -118,16 +118,22 @@
}
function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`;
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) =>
(b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual'
);
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) ───
@@ -140,11 +146,29 @@
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 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('');
@@ -187,8 +211,12 @@
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 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');
@@ -211,9 +239,7 @@
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)
myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
);
});
@@ -248,13 +274,12 @@
const myCompletions = completions.filter((c) => c.memberId === memberId);
const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) =>
(b.phase === 'active' || (!b.phase && b.status === 'active')) &&
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 pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : '';
const pEnd = pStart
? (() => {
if (cfg.period === 'weekly') {
@@ -274,8 +299,7 @@
const periodCompletions = pStart
? myCompletions.filter(
(c) =>
(c.date?.slice(0, 10) || c.date) >= pStart &&
(c.date?.slice(0, 10) || c.date) <= pEnd
(c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd
)
: myCompletions;
@@ -374,14 +398,18 @@
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');
famStore.applyRecord(
'completions',
{
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString()
} as Completion,
'create'
);
}
try {
@@ -392,14 +420,18 @@
}
} 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');
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) {
@@ -421,7 +453,7 @@
}
function rewardLabelChild(r: Reward): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`;
if (r.rewardType === 'cash') return ${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
@@ -468,7 +500,14 @@
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args); }} class="revoke-form">
<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>
@@ -487,7 +526,7 @@
{#each manualConfigs() as bc}
{@const val =
bc.rewardType === 'cash'
? `£${(Number(bc.rewardValue) / 100).toFixed(2)}`
? `£${Number(bc.rewardValue).toFixed(2)}`
: bc.rewardType === 'points'
? `${bc.rewardValue} pts`
: bc.rewardValue}
@@ -500,7 +539,13 @@
<span class="trigger-value">{val}</span>
</div>
{#if targeted}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}>
<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">
@@ -511,7 +556,13 @@
</div>
</form>
{:else}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}>
<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">
@@ -561,6 +612,7 @@
<div class="payment-right">
<span class="value">{rewardLabel(r)}</span>
<Button type="submit" size="sm" variant="primary">Issue</Button>
</div>
</div>
</form>
{/each}
@@ -569,13 +621,14 @@
method="POST"
action="?/issueAll"
use:enhance={() => {
return async (args: any) =>
handleResult(args);
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>
<Button type="submit" size="sm" variant="secondary"
>Issue All ({requested.length})</Button
>
</form>
{/if}
{/if}
@@ -746,11 +799,11 @@
</div>
<div class="badge-card cash">
<span class="badge-label">Total Cash</span>
<span class="badge-value">£{(totalCash / 100).toFixed(2)}</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 / 100).toFixed(2)}</span>
<span class="badge-value">£{pendingCash.toFixed(2)}</span>
</div>
<div class="badge-card points">
<span class="badge-label">Total Points</span>
@@ -1440,9 +1493,16 @@
font-weight: 600;
cursor: pointer;
}
.request-all-btn:hover { background: #1d4ed8; }
.request-col { border-color: #93c5fd; background: #eff6ff; }
.claim-card.requested { opacity: 0.7; }
.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;
@@ -1465,7 +1525,9 @@
color: #2563eb;
margin: 0 0 0.25rem;
}
.payment-row.outstanding { opacity: 0.6; }
.payment-row.outstanding {
opacity: 0.6;
}
h1 {
display: flex;
@@ -4,21 +4,95 @@ import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [configs, members, progress, rewards] = await Promise.all([
const [configs, templates, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'bonus-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.bonusConfigProgress(event, famId),
hono.admin.rewards(event, famId),
hono.admin.rewards(event, famId)
]);
return { configs, members, progress, rewards };
return { configs, templates, members, progress, rewards };
}
export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data: Record<string, unknown> = {
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')
};
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = '';
try {
const record = await hono.admin.create(event, 'bonus-templates', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
}
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const name = fd.get('name');
if (name) data.name = name;
const target = fd.get('target');
if (target) data.target = target;
const type = fd.get('type');
if (type) data.type = type;
const occurrence = fd.get('occurrence');
if (occurrence) data.occurrence = occurrence;
const rewardType = fd.get('rewardType');
if (rewardType) data.rewardType = rewardType;
const rewardValue = fd.get('rewardValue');
if (rewardValue) data.rewardValue = rewardValue;
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (occurrence === 'once') data.period = '';
const description = fd.get('description');
if (description) data.description = description;
try {
const record = await hono.admin.update(event, 'bonus-templates', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
}
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-templates', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
}
},
createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const phase = fd.get('phase') as string;
const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
const data: Record<string, unknown> = {
name: fd.get('name'),
target: fd.get('target'),
@@ -26,12 +100,13 @@ export const actions = {
occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue'),
phase: phase || 'ready',
status
};
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = '';
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
@@ -66,6 +141,7 @@ export const actions = {
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (occurrence === 'once') data.period = '';
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
@@ -87,7 +163,7 @@ export const actions = {
await hono.admin.remove(event, 'bonus-configs', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' });
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
}
},
@@ -95,6 +171,8 @@ export const actions = {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
const data: Record<string, unknown> = {
name: fd.get('name'),
description: fd.get('description') || '',
@@ -106,13 +184,16 @@ export const actions = {
criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0,
period: fd.get('period') || '',
memberId: fd.get('memberId') || '',
phase: 'active',
status
};
if (data.occurrence === 'once') data.period = '';
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' });
return fail(400, {
error: e instanceof Error ? e.message : 'Failed to create bonus from template'
});
}
},
@@ -124,7 +205,10 @@ export const actions = {
const target = fd.get('target') as string;
const memberId = fd.get('memberId') as string;
try {
const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null });
const record = await hono.admin.assignBonusConfig(event, famId, id, {
target,
memberId: memberId || null
});
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' });
@@ -136,12 +220,11 @@ export const actions = {
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const phase = fd.get('phase') as string;
try {
const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed');
const record = await hono.admin.completeBonusConfig(event, famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' });
return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
}
},
@@ -158,6 +241,23 @@ export const actions = {
}
},
toggleConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const currentStatus = fd.get('currentStatus') as string;
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, {
status: newStatus
});
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to toggle bonus' });
}
},
claimReward: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
@@ -178,5 +278,5 @@ export const actions = {
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
}
},
}
};
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@
}
function rewardAmount(r: any): string {
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`;
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
@@ -112,7 +112,7 @@
<td></td>
<td></td>
<td class="num"><strong>Outstanding cash</strong></td>
<td class="num"><strong>£{(totalOutstanding / 100).toFixed(2)}</strong></td>
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
<td></td>
<td></td>
</tr>