varierty of implementstions based on platform droplet issuance
This commit is contained in:
@@ -23,6 +23,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
authenticated: false,
|
||||
fams: [],
|
||||
codes: [],
|
||||
choreTemplates: [],
|
||||
bonusTemplates: [],
|
||||
platformFlags: {},
|
||||
totalFams: 0,
|
||||
totalMembers: 0,
|
||||
@@ -82,10 +84,17 @@ export const load: PageServerLoad = async (event) => {
|
||||
usedBy: usage[c.id] || []
|
||||
}));
|
||||
|
||||
const [choreTemplates, bonusTemplates] = await Promise.all([
|
||||
pbAdmin.getList('chore_templates', 'global = true'),
|
||||
pbAdmin.getList('bonus_templates', 'global = true')
|
||||
]);
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
fams: famsWithStats,
|
||||
codes: codeList,
|
||||
choreTemplates: choreTemplates as any[],
|
||||
bonusTemplates: bonusTemplates as any[],
|
||||
totalFams: fams.length,
|
||||
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
||||
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
||||
@@ -110,6 +119,8 @@ export const load: PageServerLoad = async (event) => {
|
||||
subCount: 0,
|
||||
gatedCount: 0,
|
||||
codeCount: 0,
|
||||
choreTemplates: [],
|
||||
bonusTemplates: [],
|
||||
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
||||
};
|
||||
}
|
||||
@@ -225,5 +236,113 @@ export const actions: Actions = {
|
||||
} catch (e) {
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete code' });
|
||||
}
|
||||
},
|
||||
|
||||
// Platform-owned chore/reward templates. `type` selects the target
|
||||
// collection (chore_templates | bonus_templates); the droplet is always
|
||||
// global (visible to every family as an assignable source).
|
||||
createTemplate: async (event) => {
|
||||
requirePlatform(event);
|
||||
const fd = await event.request.formData();
|
||||
const type = fd.get('type') as string;
|
||||
const name = (fd.get('name') as string || '').trim();
|
||||
const description = (fd.get('description') as string) || '';
|
||||
const icon = (fd.get('icon') as string) || '';
|
||||
const color = (fd.get('color') as string) || '#6366f1';
|
||||
if (!name) return fail(400, { error: 'Name required' });
|
||||
|
||||
try {
|
||||
if (type === 'chore') {
|
||||
await pbAdmin.create('chore_templates', {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
global: true,
|
||||
defaultFrequency: (fd.get('defaultFrequency') as string) || 'daily',
|
||||
defaultType: (fd.get('defaultType') as string) || 'points',
|
||||
defaultValue: Number(fd.get('defaultValue') || 0)
|
||||
});
|
||||
} else {
|
||||
await pbAdmin.create('bonus_templates', {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
global: true,
|
||||
target: (fd.get('target') as string) || 'individual',
|
||||
type: (fd.get('bonusType') as string) || 'threshold',
|
||||
thresholdType: (fd.get('thresholdType') as string) || 'points',
|
||||
occurrence: (fd.get('occurrence') as string) || 'recurring',
|
||||
rewardType: (fd.get('rewardType') as string) || 'points',
|
||||
rewardValue: (fd.get('rewardValue') as string) || '',
|
||||
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
||||
period: (fd.get('period') as string) || 'weekly',
|
||||
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||
}
|
||||
},
|
||||
|
||||
updateTemplate: async (event) => {
|
||||
requirePlatform(event);
|
||||
const fd = await event.request.formData();
|
||||
const kind = fd.get('kind') as string; // chore | reward
|
||||
const id = fd.get('id') as string;
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
const name = (fd.get('name') as string || '').trim();
|
||||
const description = (fd.get('description') as string) || '';
|
||||
const icon = (fd.get('icon') as string) || '';
|
||||
const color = (fd.get('color') as string) || '#6366f1';
|
||||
|
||||
try {
|
||||
if (kind === 'chore') {
|
||||
await pbAdmin.update('chore_templates', id, {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
defaultFrequency: (fd.get('defaultFrequency') as string) || 'daily',
|
||||
defaultType: (fd.get('defaultType') as string) || 'points',
|
||||
defaultValue: Number(fd.get('defaultValue') || 0)
|
||||
});
|
||||
} else {
|
||||
await pbAdmin.update('bonus_templates', id, {
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
target: (fd.get('target') as string) || 'individual',
|
||||
type: (fd.get('bonusType') as string) || 'threshold',
|
||||
thresholdType: (fd.get('thresholdType') as string) || 'points',
|
||||
occurrence: (fd.get('occurrence') as string) || 'recurring',
|
||||
rewardType: (fd.get('rewardType') as string) || 'points',
|
||||
rewardValue: (fd.get('rewardValue') as string) || '',
|
||||
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
||||
period: (fd.get('period') as string) || 'weekly',
|
||||
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteTemplate: async (event) => {
|
||||
requirePlatform(event);
|
||||
const fd = await event.request.formData();
|
||||
const kind = fd.get('kind') as string;
|
||||
const id = fd.get('id') as string;
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
try {
|
||||
await pbAdmin.remove(kind === 'chore' ? 'chore_templates' : 'bonus_templates', id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||
import { ICON_NAMES } from '$lib/templateIcons';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -20,6 +22,65 @@
|
||||
canceled: 'Canceled',
|
||||
none: 'None'
|
||||
};
|
||||
|
||||
// ── Platform template management ──
|
||||
type TplKind = 'chore' | 'reward';
|
||||
let showTplModal = $state(false);
|
||||
let editingTpl = $state<{ kind: TplKind; id: string } | null>(null);
|
||||
let tplType = $state<TplKind>('chore');
|
||||
const blankTpl = {
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'Star',
|
||||
color: '#6366f1',
|
||||
defaultFrequency: 'daily',
|
||||
defaultType: 'points',
|
||||
defaultValue: 0,
|
||||
target: 'individual',
|
||||
bonusType: 'threshold',
|
||||
thresholdType: 'points',
|
||||
occurrence: 'recurring',
|
||||
rewardType: 'points',
|
||||
rewardValue: '',
|
||||
criteriaValue: 0,
|
||||
period: 'weekly',
|
||||
isPocketMoney: false
|
||||
};
|
||||
let tplVals = $state({ ...blankTpl });
|
||||
|
||||
function openNewTpl(kind: TplKind) {
|
||||
editingTpl = null;
|
||||
tplType = kind;
|
||||
tplVals = { ...blankTpl };
|
||||
showTplModal = true;
|
||||
}
|
||||
function openEditTpl(kind: TplKind, rec: any) {
|
||||
editingTpl = { kind, id: rec.id };
|
||||
tplType = kind;
|
||||
tplVals = {
|
||||
name: rec.name || '',
|
||||
description: rec.description || '',
|
||||
icon: rec.icon || 'Star',
|
||||
color: rec.color || '#6366f1',
|
||||
defaultFrequency: rec.defaultFrequency || 'daily',
|
||||
defaultType: rec.defaultType || 'points',
|
||||
defaultValue: rec.defaultValue ?? 0,
|
||||
target: rec.target || 'individual',
|
||||
bonusType: rec.type || 'threshold',
|
||||
thresholdType: rec.thresholdType || 'points',
|
||||
occurrence: rec.occurrence || 'recurring',
|
||||
rewardType: rec.rewardType || 'points',
|
||||
rewardValue: rec.rewardValue ?? '',
|
||||
criteriaValue: rec.criteriaValue ?? 0,
|
||||
period: rec.period || 'weekly',
|
||||
isPocketMoney: !!rec.isPocketMoney
|
||||
};
|
||||
showTplModal = true;
|
||||
}
|
||||
const allTemplates = $derived([
|
||||
...(data.choreTemplates || []).map((t: any) => ({ ...t, kind: 'chore' as TplKind })),
|
||||
...(data.bonusTemplates || []).map((t: any) => ({ ...t, kind: 'reward' as TplKind }))
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
@@ -258,6 +319,184 @@
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={3} title="Templates" accent="#8b5cf6">
|
||||
<p class="muted">
|
||||
Platform-owned chore & reward droplets. Families assign these as read-only sources — they
|
||||
can't create their own.
|
||||
</p>
|
||||
<div class="tpl-grid">
|
||||
{#each allTemplates as t}
|
||||
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||
<div class="tpl-head">
|
||||
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||
<strong>{t.name}</strong>
|
||||
</div>
|
||||
<div class="tpl-meta">
|
||||
<span class="badge">{t.kind}</span>
|
||||
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
||||
</div>
|
||||
<div class="tpl-actions">
|
||||
<button class="link-btn" onclick={() => openEditTpl(t.kind, t)}>Edit</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteTemplate"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="kind" value={t.kind} />
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="link-btn danger"
|
||||
onclick={(e) => {
|
||||
if (!confirm('Delete this template?')) e.preventDefault();
|
||||
}}>Delete</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if allTemplates.length === 0}
|
||||
<p class="muted">No templates yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tpl-new">
|
||||
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
||||
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
{#if showTplModal}
|
||||
<div class="overlay" onclick={() => (showTplModal = false)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>{editingTpl ? 'Edit' : 'New'} {tplType} template</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action={editingTpl ? '?/updateTemplate' : '?/createTemplate'}
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') showTplModal = false;
|
||||
update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if editingTpl}
|
||||
<input type="hidden" name="kind" value={editingTpl.kind} />
|
||||
<input type="hidden" name="id" value={editingTpl.id} />
|
||||
{/if}
|
||||
<input type="hidden" name="type" value={tplType} />
|
||||
<label>Name <input name="name" bind:value={tplVals.name} required /></label>
|
||||
<label>Description <input name="description" bind:value={tplVals.description} /></label>
|
||||
<div class="row">
|
||||
<label>
|
||||
Icon
|
||||
<select name="icon" bind:value={tplVals.icon}>
|
||||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<span class="icon-preview"><TemplateIcon name={tplVals.icon} size={22} color={tplVals.color} /></span>
|
||||
<label>
|
||||
Colour
|
||||
<input type="color" name="color" bind:value={tplVals.color} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tplType === 'chore'}
|
||||
<label>
|
||||
Frequency
|
||||
<select name="defaultFrequency" bind:value={tplVals.defaultFrequency}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select name="defaultType" bind:value={tplVals.defaultType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Default Value
|
||||
<input type="number" step="any" name="defaultValue" bind:value={tplVals.defaultValue} />
|
||||
</label>
|
||||
{:else}
|
||||
<label>
|
||||
Target
|
||||
<select name="target" bind:value={tplVals.target}>
|
||||
<option value="individual">Individual</option>
|
||||
<option value="competitive">Competition</option>
|
||||
<option value="collaborative">Collaborative</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select name="bonusType" bind:value={tplVals.bonusType}>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="count">Count</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if tplVals.bonusType === 'threshold'}
|
||||
<label>
|
||||
Threshold by
|
||||
<select name="thresholdType" bind:value={tplVals.thresholdType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="percent">% of chores done</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label>
|
||||
Occurrence
|
||||
<select name="occurrence" bind:value={tplVals.occurrence}>
|
||||
<option value="recurring">Recurring</option>
|
||||
<option value="once">Once</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Reward Type
|
||||
<select name="rewardType" bind:value={tplVals.rewardType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="prize">Prize</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Reward Value
|
||||
<input name="rewardValue" bind:value={tplVals.rewardValue} />
|
||||
</label>
|
||||
<label>
|
||||
Target Value
|
||||
<input type="number" name="criteriaValue" bind:value={tplVals.criteriaValue} />
|
||||
</label>
|
||||
<label>
|
||||
Period
|
||||
<select name="period" bind:value={tplVals.period}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="isPocketMoney" value="true" bind:checked={tplVals.isPocketMoney} />
|
||||
Pocket Money
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (showTplModal = false)}>Cancel</button>
|
||||
<button type="submit">{editingTpl ? 'Save' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -669,4 +908,97 @@
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Template management ── */
|
||||
.tpl-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.tpl-card {
|
||||
flex: 0 1 auto;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: 4px solid #8b5cf6;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.tpl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.tpl-head strong { font-size: 0.9rem; }
|
||||
.tpl-meta { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-bottom: 0.4rem; }
|
||||
.tpl-actions { display: flex; gap: 0.75rem; }
|
||||
.tpl-new { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.row { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.icon-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.badge.pocket { background: #dcfce7; color: #166534; }
|
||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
||||
|
||||
/* ── Modal (shared) ── */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: min(440px, 100%);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.modal h3 { margin: 0 0 0.75rem; }
|
||||
.modal label {
|
||||
display: block;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
}
|
||||
.modal input,
|
||||
.modal select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.modal-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.modal-actions button[type='button'] { background: #e5e7eb; color: #374151; }
|
||||
.modal-actions button[type='submit'] { background: #6366f1; color: #fff; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user