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