add fixes to claims and seasons
This commit is contained in:
@@ -36,4 +36,7 @@ export const memberApi = {
|
||||
async claimReward(token: string, famId: string, rewardId: string) {
|
||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
|
||||
},
|
||||
async requestAllRewards(token: string, famId: string) {
|
||||
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,22 +15,22 @@
|
||||
|
||||
let navItems = $derived(isParent && memberName
|
||||
? [
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
|
||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
|
||||
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
|
||||
]
|
||||
: showChildItems
|
||||
? [
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
let footerItems = $derived([
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
...(memberName ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []),
|
||||
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
|
||||
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
|
||||
]);
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
<script lang="ts">
|
||||
let { announcement = '', children }: { announcement?: string; children?: any } = $props();
|
||||
let {
|
||||
announcement = '',
|
||||
role = '',
|
||||
seasons = [],
|
||||
children,
|
||||
}: {
|
||||
announcement?: string
|
||||
role?: string
|
||||
seasons?: { id: string; name: string; color: string; active: boolean }[]
|
||||
children?: any
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-left">
|
||||
{#if role}
|
||||
<span class="role-pill {role}">{role}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="topnav-announcement">
|
||||
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
|
||||
</div>
|
||||
<div class="topnav-actions">
|
||||
{#each seasons as s}
|
||||
<span
|
||||
class="season-pill"
|
||||
class:active={s.active}
|
||||
style="--season-color: {s.color}"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
{/each}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</header>
|
||||
@@ -23,8 +47,35 @@
|
||||
padding: 0 1.5rem;
|
||||
z-index: 90;
|
||||
transition: left 0.2s;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.topnav-announcement { flex: 1; text-align: center; }
|
||||
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
||||
.topnav-actions { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.role-pill {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.role-pill.parent { background: #fef3c7; color: #b45309; }
|
||||
.role-pill.child { background: #dbeafe; color: #1d4ed8; }
|
||||
.season-pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--season-color, #d1d5db);
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.season-pill.active {
|
||||
background: var(--season-color, #6366f1);
|
||||
color: #fff;
|
||||
border-color: var(--season-color, #6366f1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,6 +65,9 @@ export const hono = {
|
||||
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
|
||||
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) {
|
||||
return request('POST', `/api/admin/${famId}/rewards/issue-all`, { memberId, message }, sessionHeaders(event));
|
||||
},
|
||||
async bonusConfigs(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
|
||||
},
|
||||
@@ -107,5 +110,11 @@ export const hono = {
|
||||
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
||||
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
|
||||
},
|
||||
async updateFam(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
||||
return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event));
|
||||
},
|
||||
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
|
||||
return request(method, path, body, sessionHeaders(event));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,4 +42,16 @@ export const pbAdmin = {
|
||||
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||
const t = await ensureToken();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { pb } from '$lib/pocketbase';
|
||||
import type {
|
||||
Member, ChoreTemplate, AssignedChore, Completion,
|
||||
WeeklyHistory, Reward, BonusConfig, Fam,
|
||||
WeeklyHistory, Reward, BonusConfig, Fam, Season,
|
||||
} from '$lib/types';
|
||||
|
||||
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards';
|
||||
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons';
|
||||
|
||||
class FamStore {
|
||||
fam = $state<Fam | null>(null)
|
||||
@@ -15,6 +15,7 @@ class FamStore {
|
||||
history = $state<WeeklyHistory[]>([])
|
||||
rewards = $state<Reward[]>([])
|
||||
bonusConfigs = $state<BonusConfig[]>([])
|
||||
seasons = $state<Season[]>([])
|
||||
initialized = $state(false)
|
||||
famId = $state('')
|
||||
|
||||
@@ -62,7 +63,7 @@ class FamStore {
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] =
|
||||
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes, seasonsRes] =
|
||||
await Promise.all([
|
||||
pb.collection('fams').getOne(famId) as Promise<Fam>,
|
||||
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
|
||||
@@ -71,6 +72,7 @@ class FamStore {
|
||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
|
||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
|
||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
|
||||
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<Season[]>,
|
||||
])
|
||||
this.fam = famRes
|
||||
this.members = membersRes
|
||||
@@ -79,6 +81,7 @@ class FamStore {
|
||||
this.completions = completionsRes
|
||||
this.bonusConfigs = bonusConfigsRes
|
||||
this.rewards = rewardsRes
|
||||
this.seasons = seasonsRes
|
||||
this.initialized = true
|
||||
} catch (e) {
|
||||
console.error('FamStore.init failed:', e)
|
||||
@@ -101,6 +104,7 @@ class FamStore {
|
||||
{ collection: 'completions', filter: this.famId },
|
||||
{ collection: 'bonus_configs', filter: this.famId },
|
||||
{ collection: 'rewards', filter: this.famId },
|
||||
{ collection: 'seasons', filter: this.famId },
|
||||
]
|
||||
|
||||
const promises = subs.map(({ collection, filter }) => {
|
||||
@@ -136,6 +140,7 @@ class FamStore {
|
||||
case 'completions': this.completions = apply(this.completions); break
|
||||
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
|
||||
case 'rewards': this.rewards = apply(this.rewards); break
|
||||
case 'seasons': this.seasons = apply(this.seasons); break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,18 @@ export type BonusPeriod = 'weekly' | 'monthly'
|
||||
export type BonusStatus = 'active' | 'archived'
|
||||
export type BonusState = 'pending' | 'unclaimed' | 'claimed'
|
||||
|
||||
export interface Season {
|
||||
id: string
|
||||
famId: string
|
||||
name: string
|
||||
color: string
|
||||
active: boolean
|
||||
autoDisable?: string
|
||||
autoStart?: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface Fam {
|
||||
id: string
|
||||
name: string
|
||||
@@ -51,6 +63,7 @@ export interface AssignedChore {
|
||||
type: RewardType
|
||||
value: number
|
||||
customName?: string
|
||||
seasonIds?: string[]
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
@@ -102,8 +115,9 @@ export interface Reward {
|
||||
label: string
|
||||
value: number
|
||||
rewardType: BonusRewardType
|
||||
claimed: boolean
|
||||
status: 'unclaimed' | 'requested' | 'claimed'
|
||||
claimedAt?: string
|
||||
requestedAt?: string
|
||||
date: string
|
||||
created: string
|
||||
updated: string
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
export function load(event) {
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session || null;
|
||||
return { session, isParent: session !== null, role: session?.role || 'child' };
|
||||
const isParent = session !== null;
|
||||
|
||||
let famId = '';
|
||||
|
||||
if (isParent) {
|
||||
famId = session.famId;
|
||||
} else {
|
||||
const deviceToken = event.cookies.get('device_token') || '';
|
||||
if (deviceToken) {
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/seasons`, {
|
||||
headers: { 'x-device-token': deviceToken },
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
famId = body.famId || '';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { session, isParent, role: session?.role || 'child', famId };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
let session = $state<Session | null>(data.session);
|
||||
let isParent = $state(data.isParent);
|
||||
let role = $state(data.role);
|
||||
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam);
|
||||
let famName = $derived(
|
||||
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
||||
);
|
||||
|
||||
// Claim toast watcher (admin only)
|
||||
let claimToast = $state('');
|
||||
@@ -22,31 +24,29 @@
|
||||
const rewards = famStore.rewards;
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
|
||||
return;
|
||||
}
|
||||
for (const r of rewards) {
|
||||
if (r.claimed && !prevClaimedIds.has(r.id)) {
|
||||
if (r.status === 'requested' && !prevClaimedIds.has(r.id)) {
|
||||
const m = famStore.memberMap().get(r.memberId);
|
||||
const name = m?.name || 'Unknown';
|
||||
claimToast = `Claim made by ${name}`;
|
||||
setTimeout(() => claimToast = '', 5000);
|
||||
claimToast = `Claim requested by ${name}`;
|
||||
setTimeout(() => (claimToast = ''), 5000);
|
||||
}
|
||||
}
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
initPbFromCookie();
|
||||
if (session) {
|
||||
famStore.init(session.famId);
|
||||
}
|
||||
if (page.data.famId) famStore.init(page.data.famId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-shell">
|
||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||
<TopNav />
|
||||
<TopNav {role} seasons={famStore.seasons} />
|
||||
<main class="app-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
@@ -57,8 +57,24 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
|
||||
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.claim-toast {
|
||||
position: fixed;
|
||||
top: 3.5rem;
|
||||
right: 1.5rem;
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: fadein 0.2s;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.app-main {
|
||||
margin-left: 220px;
|
||||
margin-top: 48px;
|
||||
|
||||
@@ -92,6 +92,20 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
issueAll: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const memberId = fd.get('memberId') as string;
|
||||
const message = fd.get('message') as string;
|
||||
try {
|
||||
const result = await hono.admin.issueAllRewards(event, famId, memberId, message || undefined);
|
||||
return { count: result.count };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
|
||||
}
|
||||
},
|
||||
|
||||
revoke: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,9 +57,9 @@ import type {
|
||||
let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') 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.claimed) as Reward[]);
|
||||
let completedRewards = $derived(allRewards.filter((r) => r.status === 'claimed') as Reward[]);
|
||||
let unclaimedRewards = $derived(
|
||||
allRewards.filter((r) => !r.claimed) as Reward[]
|
||||
allRewards.filter((r) => r.status === 'unclaimed') as Reward[]
|
||||
);
|
||||
let progressByConfigId = $derived.by(() => {
|
||||
const map = new Map<string, BonusConfigWithProgress>();
|
||||
|
||||
@@ -4,12 +4,13 @@ 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 [templates, members, assigned] = await Promise.all([
|
||||
const [templates, members, assigned, seasons] = await Promise.all([
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.list(event, 'seasons', famId),
|
||||
]);
|
||||
return { templates, members, assigned };
|
||||
return { templates, members, assigned, seasons };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -80,29 +81,6 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
assignChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const data = {
|
||||
memberId: fd.get('memberId'),
|
||||
templateId: fd.get('templateId'),
|
||||
frequency: fd.get('frequency'),
|
||||
type: fd.get('type'),
|
||||
value: parseFloat(fd.get('value') as string) || 0,
|
||||
customName: fd.get('customName') || undefined,
|
||||
};
|
||||
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
|
||||
return fail(400, { error: 'Member, template, frequency, and type are required' });
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
|
||||
}
|
||||
},
|
||||
|
||||
updateAssignedChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
@@ -113,10 +91,14 @@ export const actions = {
|
||||
const type = fd.get('type');
|
||||
const value = fd.get('value');
|
||||
const customName = fd.get('customName');
|
||||
const seasonIdsRaw = fd.get('seasonIds') as string;
|
||||
if (frequency) data.frequency = frequency;
|
||||
if (type) data.type = type;
|
||||
if (value) data.value = parseFloat(value as string) || 0;
|
||||
data.customName = (customName as string) || undefined;
|
||||
if (seasonIdsRaw || seasonIdsRaw === '') {
|
||||
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
||||
return { record };
|
||||
@@ -125,16 +107,4 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
unassignChore: 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 {
|
||||
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types';
|
||||
import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -30,18 +30,45 @@
|
||||
let editTplType = $state('points')
|
||||
let editTplValue = $state(10)
|
||||
|
||||
let seasonFilter = $state('all')
|
||||
let editSeasonIds = $state<string[]>([])
|
||||
|
||||
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
|
||||
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
|
||||
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
|
||||
let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
|
||||
|
||||
let seasons = $state(famStore.initialized ? famStore.seasons : (data.seasons || []))
|
||||
|
||||
let filteredAssigned = $derived(
|
||||
seasonFilter === 'all'
|
||||
? assigned
|
||||
: assigned.filter(a => a.seasonIds?.includes(seasonFilter))
|
||||
)
|
||||
|
||||
function assignedForMember(memberId: string): AssignedChore[] {
|
||||
return filteredAssigned.filter(a => a.memberId === memberId)
|
||||
}
|
||||
|
||||
function assignedForMemberAll(memberId: string): AssignedChore[] {
|
||||
return assigned.filter(a => a.memberId === memberId)
|
||||
}
|
||||
|
||||
function isGlobalChore(a: AssignedChore): boolean {
|
||||
return !a.seasonIds || a.seasonIds.length === 0
|
||||
}
|
||||
|
||||
function templateName(templateId: string): string {
|
||||
return famStore.templateMap().get(templateId)?.name || '?'
|
||||
}
|
||||
|
||||
function seasonName(id: string): string {
|
||||
return seasons.find(s => s.id === id)?.name || id
|
||||
}
|
||||
|
||||
function seasonNames(a: AssignedChore): string {
|
||||
return (a.seasonIds || []).map(id => seasonName(id)).join(', ') || 'Global'
|
||||
}
|
||||
|
||||
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
|
||||
const t = famStore.templateMap().get(templateId)
|
||||
if (!t) return { frequency: 'daily', type: 'points', value: 10 }
|
||||
@@ -65,25 +92,16 @@
|
||||
if (!tid) return
|
||||
draggedTemplateId = null
|
||||
|
||||
const s = page.data.session as any
|
||||
if (!s) return
|
||||
const def = getDefault(tid)
|
||||
const fd = new FormData()
|
||||
fd.set('memberId', memberId)
|
||||
fd.set('templateId', tid)
|
||||
fd.set('frequency', def.frequency)
|
||||
fd.set('type', def.type)
|
||||
fd.set('value', String(def.value))
|
||||
|
||||
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
|
||||
const body: Record<string, unknown> = { memberId, templateId: tid, frequency: def.frequency, type: def.type, value: def.value }
|
||||
if (seasonFilter !== 'all') body.seasonIds = [seasonFilter]
|
||||
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'Content-Type': 'application/json', 'x-session-famid': s.famId, 'x-session-userid': s.userId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
if (body.record) {
|
||||
assigned = [body.record as AssignedChore, ...assigned]
|
||||
famStore.applyRecord('assigned_chores', body.record, 'create')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(a: AssignedChore) {
|
||||
@@ -92,6 +110,7 @@
|
||||
editType = a.type
|
||||
editValue = a.value
|
||||
editCustomName = a.customName || ''
|
||||
editSeasonIds = a.seasonIds ? [...a.seasonIds] : []
|
||||
showEditModal = true
|
||||
}
|
||||
|
||||
@@ -138,6 +157,7 @@
|
||||
type: editType,
|
||||
value: editValue,
|
||||
customName: editCustomName || undefined,
|
||||
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
|
||||
} as AssignedChore
|
||||
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
||||
}
|
||||
@@ -179,6 +199,16 @@
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="season-filter">
|
||||
<label>Season:</label>
|
||||
<select bind:value={seasonFilter}>
|
||||
<option value="all">All Chores</option>
|
||||
{#each seasons as s}
|
||||
<option value={s.id}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={3}>
|
||||
<div style="margin-bottom:0.75rem">
|
||||
@@ -233,12 +263,28 @@
|
||||
<strong>{a.customName || tName}</strong>
|
||||
<span class="badge">{a.frequency}</span>
|
||||
<span class="badge type">{a.type}</span>
|
||||
{#if seasonFilter !== 'all' && isGlobalChore(a)}
|
||||
<span class="badge season">Add to</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
|
||||
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
|
||||
<input type="hidden" name="id" value={a.id} />
|
||||
<button type="submit" class="del-btn" title="Remove assignment">×</button>
|
||||
</form>
|
||||
<div class="card-meta">
|
||||
<span class="season-tags">{seasonNames(a)}</span>
|
||||
</div>
|
||||
<button
|
||||
class="del-btn"
|
||||
title="Remove assignment"
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const s = page.data.session as any;
|
||||
if (!s) return;
|
||||
const res = await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId },
|
||||
});
|
||||
if (res.ok) famStore.applyRecord('assigned_chores', { id: a.id }, 'delete');
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if assignedForMember(m.id).length === 0}
|
||||
@@ -333,6 +379,7 @@
|
||||
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
|
||||
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
|
||||
<input name="id" type="hidden" value={editingAssignment.id} />
|
||||
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
|
||||
<label>
|
||||
Custom Name
|
||||
<input name="customName" bind:value={editCustomName} placeholder="Override template name" />
|
||||
@@ -351,11 +398,33 @@
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<input name="value" type="number" step="any" bind:value={editValue} required />
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<label>
|
||||
Value
|
||||
<input name="value" type="number" step="any" bind:value={editValue} required />
|
||||
</label>
|
||||
<label>
|
||||
Seasons
|
||||
<div class="season-checklist">
|
||||
{#each seasons as s}
|
||||
<label class="check-item">
|
||||
<input type="checkbox" checked={editSeasonIds.includes(s.id)} onchange={() => {
|
||||
if (editSeasonIds.includes(s.id)) {
|
||||
editSeasonIds = editSeasonIds.filter(x => x !== s.id);
|
||||
} else {
|
||||
editSeasonIds = [...editSeasonIds, s.id];
|
||||
}
|
||||
}} />
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
{s.name}
|
||||
</label>
|
||||
{/each}
|
||||
<label class="check-item">
|
||||
<input type="checkbox" checked={editSeasonIds.length === 0} onchange={() => editSeasonIds = []} />
|
||||
Global (no season)
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={closeEdit}>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
@@ -384,6 +453,15 @@
|
||||
.edit-btn:hover { color: #6366f1; }
|
||||
.del-btn:hover { color: #dc2626; }
|
||||
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.season-filter { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.season-filter label { font-size: 0.85rem; color: #374151; font-weight: 500; }
|
||||
.season-filter select { padding: 0.35rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; }
|
||||
.badge.season { background: #e0f2fe; color: #0369a1; }
|
||||
.card-meta { font-size: 0.7rem; color: #9ca3af; flex-shrink: 0; }
|
||||
.season-tags { font-size: 0.7rem; color: #9ca3af; }
|
||||
.season-checklist { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.25rem; }
|
||||
.check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; cursor: pointer; }
|
||||
.check-item input[type="checkbox"] { width: auto; margin: 0; }
|
||||
|
||||
.template { cursor: grab; border-left: 3px solid #6366f1; }
|
||||
.template:active { cursor: grabbing; opacity: 0.8; }
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
}
|
||||
|
||||
function rewardStatus(r: any): string {
|
||||
if (!r.claimed) return 'Outstanding';
|
||||
if (r.status === 'unclaimed') return 'Outstanding';
|
||||
if (r.status === 'requested') return 'Requested';
|
||||
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
||||
return 'Claimed';
|
||||
}
|
||||
|
||||
function isOutstanding(r: any): boolean { return !r.claimed; }
|
||||
function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
|
||||
function isRequested(r: any): boolean { return r.status === 'requested'; }
|
||||
|
||||
let sorted = $derived([...rewards].sort((a: any, b: any) => {
|
||||
const da = a.date || a.id || '';
|
||||
@@ -50,7 +52,7 @@
|
||||
|
||||
let totalOutstanding = $derived(
|
||||
rewards
|
||||
.filter((r: any) => isOutstanding(r))
|
||||
.filter((r: any) => r.status !== 'claimed')
|
||||
.filter((r: any) => r.rewardType === 'cash')
|
||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||
)
|
||||
@@ -81,7 +83,7 @@
|
||||
<tbody>
|
||||
{#each sorted as r}
|
||||
{@const outstanding = isOutstanding(r)}
|
||||
<tr class:claimed={!outstanding}>
|
||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
||||
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||
@@ -90,7 +92,7 @@
|
||||
<td>{r.label}</td>
|
||||
<td class="num">{rewardAmount(r)}</td>
|
||||
<td>
|
||||
<span class="status-badge" class:outstanding class:claimed-status={!outstanding}>
|
||||
<span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
|
||||
{rewardStatus(r)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -129,9 +131,11 @@
|
||||
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
|
||||
tr.claimed { opacity: 0.5; }
|
||||
tr.claimed td { color: #9ca3af; }
|
||||
tr.requested { background: #eff6ff; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
|
||||
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
|
||||
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
|
||||
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
|
||||
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
|
||||
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,12 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
export async function load(event: RequestEvent) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const [members, fam] = await Promise.all([
|
||||
const [members, fam, seasons] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.fam(event, famId),
|
||||
hono.admin.list(event, 'seasons', famId),
|
||||
]);
|
||||
return { members, fam };
|
||||
return { members, fam, seasons };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -54,4 +55,59 @@ export const actions = {
|
||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||
return await hono.admin.updatePayday(event, famId, payday);
|
||||
},
|
||||
|
||||
createSeason: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
return await hono.admin.create(event, 'seasons', famId, { name, color: color || '#6366f1', active: true });
|
||||
},
|
||||
|
||||
deleteSeason: async (event: RequestEvent) => {
|
||||
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;
|
||||
if (!id) return { error: 'Season ID required' };
|
||||
|
||||
const assigned = await hono.admin.list(event, 'assigned-chores', famId);
|
||||
const toDelete = (Array.isArray(assigned) ? assigned : [])
|
||||
.filter((a: any) => a.seasonIds?.includes(id));
|
||||
const deletedIds = toDelete.map((a: any) => a.id);
|
||||
|
||||
await Promise.all(toDelete.map((a: any) =>
|
||||
hono.admin.remove(event, 'assigned-chores', famId, a.id)
|
||||
));
|
||||
|
||||
await hono.admin.remove(event, 'seasons', famId, id);
|
||||
|
||||
return { deletedChoreIds: deletedIds };
|
||||
},
|
||||
|
||||
completeWeek: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
|
||||
}
|
||||
},
|
||||
|
||||
generateData: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const days = parseInt(fd.get('days') as string || '7', 10);
|
||||
try {
|
||||
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { onDestroy } from 'svelte';
|
||||
)
|
||||
|
||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
||||
let deletingSeason = $state<any>(null)
|
||||
|
||||
function copy(url: string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
@@ -140,12 +141,73 @@ import { onDestroy } from 'svelte';
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Seasons" cols={1}>
|
||||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||||
|
||||
<form method="POST" action="?/createSeason" use:enhance class="inline">
|
||||
<input name="name" placeholder="Season name" required />
|
||||
<input name="color" type="color" value="#6366f1" class="color-input" />
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{#each data.seasons as s}
|
||||
<li>
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
{s.name}
|
||||
<Button variant="danger" size="sm" onclick={() => deletingSeason = s}>Remove</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<!-- Delete Season Modal -->
|
||||
{#if deletingSeason}
|
||||
<div class="overlay" onclick={() => deletingSeason = null} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||
<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;
|
||||
}
|
||||
}}}>
|
||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => deletingSeason = null}>Cancel</button>
|
||||
<button type="submit" class="danger">Delete Season</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card title="Data" cols={1}>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{#if data.fam?.featureFlags?.debugMode}
|
||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||
<div class="actions">
|
||||
<form method="POST" action="?/completeWeek" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Week completed!'); await update(); }; }}>
|
||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||
</form>
|
||||
<form method="POST" action="?/generateData" use:enhance={() => { return async ({ result, update }) => { if (result.type === 'success') alert('Test data generated!'); await update(); }; }}>
|
||||
<input type="hidden" name="days" value="7" />
|
||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</CardGrid>
|
||||
|
||||
<style>
|
||||
@@ -168,4 +230,13 @@ import { onDestroy } from 'svelte';
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
|
||||
.inline { display: inline; margin: 0; }
|
||||
.color-input { width: 40px; height: 34px; padding: 0; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; }
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
|
||||
.modal h3 { margin: 0 0 0.75rem; }
|
||||
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||
.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: #dc2626; color: white; }
|
||||
.warning { color: #dc2626; font-size: 0.9rem; background: #fef2f2; padding: 0.6rem; border-radius: 6px; margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,81 @@
|
||||
import { pbAdmin } from '$lib/server/pb-admin';
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import { PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export async function load() {
|
||||
const fams = await pbAdmin.getList('fams');
|
||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
||||
const [members, rewards] = await Promise.all([
|
||||
pbAdmin.getList('members', `famId = '${fam.id}'`),
|
||||
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
||||
]);
|
||||
return {
|
||||
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
|
||||
memberCount: members.length,
|
||||
claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length,
|
||||
totalRewards: rewards.length,
|
||||
};
|
||||
}));
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const session = cookies.get('platform_session');
|
||||
if (!session) {
|
||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
|
||||
}
|
||||
|
||||
const totalFams = fams.length;
|
||||
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
||||
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
||||
try {
|
||||
const fams = await pbAdmin.getList('fams');
|
||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
||||
const [members, rewards, famAdmins] = await Promise.all([
|
||||
pbAdmin.getList('members', `famId = '${fam.id}'`),
|
||||
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
||||
pbAdmin.getList('fam_admins', `famId = '${fam.id}'`),
|
||||
]);
|
||||
return {
|
||||
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
|
||||
memberCount: members.length,
|
||||
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
||||
totalRewards: rewards.length,
|
||||
parentEmail: (famAdmins as any[])?.[0]?.email || '',
|
||||
featureFlags: fam.featureFlags || {},
|
||||
};
|
||||
}));
|
||||
|
||||
return { fams: famsWithStats, totalFams, totalMembers, totalRewards };
|
||||
}
|
||||
const totalFams = fams.length;
|
||||
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
||||
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
||||
|
||||
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards };
|
||||
} catch {
|
||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, cookies }) => {
|
||||
const fd = await request.formData();
|
||||
const email = fd.get('email') as string;
|
||||
const password = fd.get('password') as string;
|
||||
|
||||
if (email === PB_EMAIL && password === PB_PASSWORD) {
|
||||
cookies.set('platform_session', 'authenticated', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
return fail(400, { error: 'Invalid credentials' });
|
||||
},
|
||||
|
||||
logout: async ({ cookies }) => {
|
||||
cookies.delete('platform_session', { path: '/' });
|
||||
throw redirect(303, '/admin');
|
||||
},
|
||||
|
||||
toggleFeatureFlag: async ({ request, cookies }) => {
|
||||
const session = cookies.get('platform_session');
|
||||
if (!session) return fail(401, { error: 'Not authenticated' });
|
||||
|
||||
const fd = await request.formData();
|
||||
const famId = fd.get('famId') as string;
|
||||
const flag = fd.get('flag') as string;
|
||||
|
||||
try {
|
||||
const fam = await pbAdmin.getOne('fams', famId);
|
||||
const flags = fam.featureFlags || {};
|
||||
flags[flag] = !flags[flag];
|
||||
await pbAdmin.update('fams', famId, { featureFlags: flags });
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,37 +1,226 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
import { enhance } from '$app/forms';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<h1>Super Admin</h1>
|
||||
{#if !data.authenticated}
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>Platform Admin</h1>
|
||||
<p class="subtitle">Sign in to access the admin dashboard</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="card"><strong>{data.totalFams}</strong> Families</div>
|
||||
<div class="card"><strong>{data.totalMembers}</strong> Members</div>
|
||||
<div class="card"><strong>{data.totalRewards}</strong> Rewards</div>
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<h2>Families</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Slug</th><th>Members</th><th>Rewards</th><th>Invite Code</th></tr></thead>
|
||||
<tbody>
|
||||
{#each data.fams as fam}
|
||||
<tr>
|
||||
<td><a href="/{fam.slug}/admin">{fam.name}</a></td>
|
||||
<td>{fam.slug}</td>
|
||||
<td>{fam.memberCount}</td>
|
||||
<td>{fam.claimedRewards}/{fam.totalRewards}</td>
|
||||
<td><code>{fam.inviteCode}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="POST" action="?/login" use:enhance>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required />
|
||||
</div>
|
||||
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ViewHeader title="Platform Admin" subtitle="Manage families and platform settings" />
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={1} title="Overview" accent="#6366f1">
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.totalFams}</span>
|
||||
<span class="stat-label">Families</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.totalMembers}</span>
|
||||
<span class="stat-label">Members</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.totalRewards}</span>
|
||||
<span class="stat-label">Rewards</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card cols={2} title="Families" accent="#059669">
|
||||
<table class="fam-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Parent</th>
|
||||
<th>Members</th>
|
||||
<th>Claims</th>
|
||||
<th>Debug</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.fams as fam}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/{fam.slug}/admin">{fam.name}</a>
|
||||
<span class="slug">/{fam.slug}</span>
|
||||
</td>
|
||||
<td>{fam.parentEmail || '—'}</td>
|
||||
<td>{fam.memberCount}</td>
|
||||
<td>
|
||||
{#if fam.requestedRewards > 0}
|
||||
<span class="badge pending">{fam.requestedRewards} pending</span>
|
||||
{:else}
|
||||
<span class="badge none">None</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="?/toggleFeatureFlag" use:enhance>
|
||||
<input type="hidden" name="famId" value={fam.id} />
|
||||
<input type="hidden" name="flag" value="debugMode" />
|
||||
<button
|
||||
type="submit"
|
||||
class="toggle-btn"
|
||||
class:active={fam.featureFlags?.debugMode}
|
||||
>
|
||||
{fam.featureFlags?.debugMode ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/{fam.slug}/admin" class="link">Dashboard</a>
|
||||
<a href="/{fam.slug}" class="link">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<form method="POST" action="?/logout" class="logout-form">
|
||||
<Button type="submit" variant="ghost" size="sm">Sign Out</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stats { display: flex; gap: 1rem; margin: 1rem 0; }
|
||||
.card { padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; flex: 1; text-align: center; }
|
||||
.card strong { font-size: 1.5rem; display: block; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||
th, td { padding: 0.5rem; border: 1px solid #e5e7eb; text-align: left; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.login-card h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.fam-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.fam-table th, .fam-table td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
.fam-table th {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.slug {
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge.pending { background: #fef3c7; color: #92400e; }
|
||||
.badge.none { background: #f3f4f6; color: #6b7280; }
|
||||
.toggle-btn {
|
||||
background: #e5e7eb;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: #059669;
|
||||
color: white;
|
||||
}
|
||||
.link {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
.logout-form {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user