1615 lines
42 KiB
Svelte
1615 lines
42 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import { page } from '$app/state';
|
||
import { famStore } from '$lib/stores/fam.svelte';
|
||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
|
||
import { formatHumanDate } from '$lib/format';
|
||
import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone';
|
||
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||
|
||
// ── Chevron SVG icons ──
|
||
const CHEVRON_DOWN =
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||
const CHEVRON_UP =
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
||
|
||
let { data, form } = $props();
|
||
|
||
let showEditModal = $state(false);
|
||
let showTodoModal = $state(false);
|
||
let editingAssignment = $state<AssignedChore | null>(null);
|
||
let editMemberId = $state('');
|
||
let draggedTemplateId = $state<string | null>(null);
|
||
let todoMemberId = $state('');
|
||
|
||
// Assign-from-template modal (platform templates are copied into the family
|
||
// as assigned chores; the fam admin amends before saving).
|
||
let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null);
|
||
let assignName = $state('');
|
||
let assignDescription = $state('');
|
||
let assignColor = $state('');
|
||
let assignIcon = $state('');
|
||
let assignFreq = $state('daily');
|
||
let assignType = $state('points');
|
||
let assignValue = $state(10);
|
||
let assignSeasonIds = $state<string[]>([]);
|
||
|
||
// Todo form state
|
||
let todoName = $state('');
|
||
let todoType = $state<'points' | 'money' | 'emoji'>('points');
|
||
let todoValue = $state(10);
|
||
let todoEmoji = $state('🎉');
|
||
let showTodoEmojiPicker = $state(false);
|
||
let todoStart = $state<'now' | 'next-week'>('now');
|
||
let todoCompleteBy = $state<'next-week' | 'custom'>('next-week');
|
||
let todoCustomDate = $state('');
|
||
|
||
// Curated celebration emojis for todos
|
||
const EMOJI_CHOICES = [
|
||
'🎉',
|
||
'⭐',
|
||
'🏆',
|
||
'💪',
|
||
'🧹',
|
||
'📚',
|
||
'🦸',
|
||
'✨',
|
||
'🌟',
|
||
'🥇',
|
||
'🎯',
|
||
'💚',
|
||
'🚀',
|
||
'🦄',
|
||
'🌈',
|
||
'🍀',
|
||
'🎨',
|
||
'🎸',
|
||
'⚽',
|
||
'🍰'
|
||
];
|
||
|
||
let editFreq = $state('daily');
|
||
let editType = $state('points');
|
||
let editValue = $state(10);
|
||
let editCustomName = $state('');
|
||
let editDescription = $state('');
|
||
let editColor = $state('');
|
||
let editIcon = $state('');
|
||
let editEmoji = $state('🎉');
|
||
let editTodoCompleteBy = $state('');
|
||
let editTodoStartDate = $state('');
|
||
|
||
let seasonFilter = $state('all');
|
||
let editSeasonIds = $state<string[]>([]);
|
||
|
||
// Platform-owned templates only — families assign these, they don't create.
|
||
let templates = $derived(
|
||
(
|
||
(famStore.initialized
|
||
? (famStore.templates as ChoreTemplate[])
|
||
: (data.templates as ChoreTemplate[])) || []
|
||
).filter((t: ChoreTemplate) => t.global)
|
||
);
|
||
let members = $state(
|
||
famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || []
|
||
);
|
||
let assigned = $derived(
|
||
famStore.initialized
|
||
? (famStore.assigned as AssignedChore[])
|
||
: (data.assigned as AssignedChore[]) || []
|
||
);
|
||
|
||
let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []);
|
||
// Only active seasons are selectable for assignment / filtering.
|
||
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
|
||
let completions = $state((data.completions as Completion[]) || []);
|
||
// $effect(() => console.log(seasons, activeSeasons));
|
||
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 && !a.isTodo);
|
||
}
|
||
|
||
function todosForMember(memberId: string): AssignedChore[] {
|
||
return filteredAssigned.filter((a) => a.memberId === memberId && a.isTodo);
|
||
}
|
||
|
||
function assignedForMemberAll(memberId: string): AssignedChore[] {
|
||
return assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||
}
|
||
|
||
function isGlobalChore(a: AssignedChore): boolean {
|
||
return !a.seasonIds || a.seasonIds.length === 0;
|
||
}
|
||
|
||
function templateName(templateId: string | undefined): string {
|
||
if (!templateId) return '?';
|
||
return famStore.templateMap().get(templateId)?.name || '?';
|
||
}
|
||
|
||
function seasonName(id: string): string {
|
||
return seasons.find((s) => s.id === id)?.name || id;
|
||
}
|
||
|
||
function seasonColor(id: string): string {
|
||
return seasons.find((s) => s.id === id)?.color || '#9ca3af';
|
||
}
|
||
|
||
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 };
|
||
return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue };
|
||
}
|
||
|
||
function handleDragStart(e: DragEvent, templateId: string) {
|
||
draggedTemplateId = templateId;
|
||
e.dataTransfer?.setData('text/plain', templateId);
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
|
||
}
|
||
|
||
function handleDragOver(e: DragEvent) {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
|
||
async function handleDrop(e: DragEvent, memberId: string) {
|
||
e.preventDefault();
|
||
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId;
|
||
draggedTemplateId = null;
|
||
if (!tid) return;
|
||
const t = templates.find((x) => x.id === tid);
|
||
if (!t) return;
|
||
assignModal = { t, memberId };
|
||
assignName = t.name || '';
|
||
assignDescription = t.description || '';
|
||
assignColor = t.color || '';
|
||
assignIcon = t.icon || '';
|
||
assignFreq = t.defaultFrequency;
|
||
assignType = t.defaultType;
|
||
assignValue = t.defaultValue;
|
||
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
|
||
}
|
||
|
||
async function assignChore() {
|
||
if (!assignModal) return;
|
||
const s = page.data.session as any;
|
||
if (!s) return;
|
||
const body: Record<string, unknown> = {
|
||
memberId: assignModal.memberId,
|
||
templateId: assignModal.t.id,
|
||
customName: assignName,
|
||
description: assignDescription,
|
||
color: assignColor,
|
||
icon: assignIcon,
|
||
frequency: assignFreq,
|
||
type: assignType,
|
||
value: assignValue
|
||
};
|
||
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
|
||
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-session-famid': s.famId,
|
||
'x-session-userid': s.userId
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
assignModal = null;
|
||
}
|
||
|
||
// Accordion state per member: which section is open
|
||
let accordionState = $state<Record<string, { chores: boolean; todos: boolean }>>({});
|
||
|
||
function toggleAccordion(memberId: string, section: 'chores' | 'todos') {
|
||
const cur = accordionState[memberId] ?? { chores: true, todos: true };
|
||
accordionState[memberId] = { ...cur, [section]: !cur[section] };
|
||
}
|
||
|
||
function openTodo(memberId: string) {
|
||
todoMemberId = memberId;
|
||
todoName = '';
|
||
todoType = 'points';
|
||
todoValue = 10;
|
||
todoEmoji = '🎉';
|
||
showTodoEmojiPicker = false;
|
||
todoStart = 'now';
|
||
todoCompleteBy = 'custom';
|
||
todoCustomDate = defaultCompleteByDate();
|
||
showTodoModal = true;
|
||
}
|
||
|
||
// Sort todos by completeBy (soonest first), then by startDate
|
||
function todoSort(a: AssignedChore, b: AssignedChore): number {
|
||
const da = a.completeBy || '9999-99-99';
|
||
const db = b.completeBy || '9999-99-99';
|
||
if (da !== db) return da.localeCompare(db);
|
||
return (a.startDate || '').localeCompare(b.startDate || '');
|
||
}
|
||
|
||
// Family payday anchor + resolved tz for week-boundary maths.
|
||
const famPayday = $derived(
|
||
(famStore.fam as any as { payday?: number })?.payday != null
|
||
? Number((famStore.fam as any).payday)
|
||
: 1
|
||
);
|
||
const famTz = $derived(resolveTz((famStore.fam as any)?.timezone || 'auto'));
|
||
|
||
// A completed todo stays visible (green "TBC completed") for the rest of the
|
||
// week it was completed; once the next payday rolls around (a new week
|
||
// starts) it drops off the admin Todos list.
|
||
function comprisesTodo(todo: AssignedChore): boolean {
|
||
if (!isTodoCompleted(todo.id)) return false;
|
||
const completion = completions.find((c: Completion) => c.assignedChoreId === todo.id);
|
||
const done = (completion?.date || '').slice(0, 10);
|
||
if (!done) return true;
|
||
const nextPayday = nextPaydayAfter(done, famPayday, famTz);
|
||
return todayInTz(famTz) >= nextPayday;
|
||
}
|
||
|
||
// All todos shown for a member — but once the week they were completed in is
|
||
// over (payday has passed) the completed todo is hidden from the list.
|
||
function visibleTodosForMember(memberId: string): AssignedChore[] {
|
||
return todosForMember(memberId)
|
||
.filter((a) => !comprisesTodo(a))
|
||
.sort(todoSort);
|
||
}
|
||
|
||
// 7 days from today as YYYY-MM-DD for the todo modal default
|
||
function defaultCompleteByDate(): string {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + 7);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// Check if a todo has been completed by the child (todos don't reset daily like chores)
|
||
function isTodoCompleted(todoId: string): boolean {
|
||
return completions.some((c: Completion) => c.assignedChoreId === todoId);
|
||
}
|
||
|
||
// Traffic light for admin todo cards (same logic as child page)
|
||
function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
|
||
if (!a.completeBy) return 'blue';
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const days = Math.round(
|
||
(new Date(a.completeBy + 'T00:00:00').getTime() - new Date(today + 'T00:00:00').getTime()) /
|
||
86400000
|
||
);
|
||
if (days <= 0) return 'black';
|
||
if (days === 1) return 'red';
|
||
if (days === 2) return 'orange';
|
||
if (days === 3) return 'green';
|
||
return 'blue';
|
||
}
|
||
|
||
function openEdit(a: AssignedChore) {
|
||
editingAssignment = a;
|
||
editFreq = a.frequency;
|
||
editType = a.type;
|
||
editValue = a.value;
|
||
editCustomName = a.customName || '';
|
||
editDescription = a.description || '';
|
||
editColor = a.color || '';
|
||
editIcon = a.icon || '';
|
||
editEmoji = a.emoji || '🎉';
|
||
editSeasonIds = a.seasonIds ? [...a.seasonIds] : [];
|
||
editTodoCompleteBy = a.completeBy || '';
|
||
editTodoStartDate = a.startDate || '';
|
||
showEditModal = true;
|
||
}
|
||
|
||
function closeEdit() {
|
||
showEditModal = false;
|
||
editingAssignment = null;
|
||
}
|
||
|
||
function enhanceUpdateAssigned(e: any) {
|
||
const data = e?.data;
|
||
if (data && editingAssignment && !editingAssignment.isTodo) {
|
||
data.set('seasonIds', editSeasonIds.join(','));
|
||
data.set('color', editColor);
|
||
data.set('icon', editIcon);
|
||
data.set('description', editDescription);
|
||
}
|
||
return async ({ result }: any) => {
|
||
if (result.type === 'success' && editingAssignment) {
|
||
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id);
|
||
if (idx !== -1) {
|
||
const updates: Partial<AssignedChore> = {
|
||
customName: editCustomName || undefined
|
||
};
|
||
if (editingAssignment.isTodo) {
|
||
updates.type = editType;
|
||
updates.value = editType === 'emoji' ? 0 : editValue;
|
||
updates.emoji = editType === 'emoji' ? editEmoji : undefined;
|
||
updates.completeBy = editTodoCompleteBy;
|
||
updates.startDate = editTodoStartDate;
|
||
} else {
|
||
updates.frequency = editFreq;
|
||
updates.type = editType;
|
||
updates.value = editValue;
|
||
updates.seasonIds = editSeasonIds.length > 0 ? editSeasonIds : [];
|
||
}
|
||
assigned[idx] = { ...assigned[idx], ...updates } as AssignedChore;
|
||
}
|
||
}
|
||
closeEdit();
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<ViewHeader title="Chore Assignment" hero />
|
||
|
||
{#if form?.error}
|
||
<p class="error">{form.error}</p>
|
||
{/if}
|
||
|
||
<CardGrid stackTablet>
|
||
<!-- Templates Section (at top, like bonuses) -->
|
||
<Card cols={3}>
|
||
<div class="template-section">
|
||
<div class="template-header">
|
||
<h3>Templates — Drag to assign</h3>
|
||
</div>
|
||
<div class="template-list">
|
||
{#each templates as t}
|
||
<div
|
||
class="card template"
|
||
draggable="true"
|
||
ondragstart={(e) => handleDragStart(e, t.id)}
|
||
style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}"
|
||
>
|
||
<div class="card-body">
|
||
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
|
||
<strong>{t.name}</strong>
|
||
<span class="badge">{t.defaultFrequency}</span>
|
||
<span class="badge type">{t.defaultType}</span>
|
||
</div>
|
||
<div class="card-value">
|
||
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{#if templates.length === 0}
|
||
<p class="empty">No templates yet</p>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<!-- Member Chore Assignment Kanban (3-column grid) -->
|
||
<Card cols={3} scrollX>
|
||
<div class="kanban-toolbar">
|
||
<div class="season-filter">
|
||
<label>Season:</label>
|
||
<select bind:value={seasonFilter}>
|
||
<option value="all">All Chores</option>
|
||
{#each activeSeasons as s}
|
||
<option value={s.id}>{s.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="kanban">
|
||
{#each members as m}
|
||
<div
|
||
class="column member-col"
|
||
style="border-top-color: {m.color}; --mc: {m.color}"
|
||
ondragover={handleDragOver}
|
||
ondrop={(e) => handleDrop(e, m.id)}
|
||
>
|
||
<h3>
|
||
<span class="dot" style="background:{m.color}"></span>
|
||
{m.name}
|
||
</h3>
|
||
|
||
<!-- TODOS accordion wrapper -->
|
||
<div
|
||
class="accordion-wrapper accordion-todos"
|
||
class:open={accordionState[m.id]?.todos ?? true}
|
||
>
|
||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
||
<span class="accordion-title">
|
||
<span class="accordion-icon">{@html todoIcon}</span>
|
||
<span class="accordion-label">Todos</span>
|
||
</span>
|
||
<span class="accordion-chevron"
|
||
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||
>
|
||
</button>
|
||
<span class="accordion-count" style="background:{m.color}"
|
||
>{visibleTodosForMember(m.id).length}</span
|
||
>
|
||
{#if accordionState[m.id]?.todos ?? true}
|
||
<div class="accordion-body">
|
||
{#each visibleTodosForMember(m.id) as a}
|
||
{@const completed = isTodoCompleted(a.id)}
|
||
{@const urgency = todoUrgency(a)}
|
||
<div
|
||
class="todo-admin-card"
|
||
class:todo-completed={completed}
|
||
class:todo-blue-bg={!completed && urgency === 'blue'}
|
||
class:todo-green-bg={!completed && urgency === 'green'}
|
||
class:todo-orange-bg={!completed && urgency === 'orange'}
|
||
class:todo-red-bg={!completed && urgency === 'red'}
|
||
class:todo-black-bg={!completed && urgency === 'black'}
|
||
onclick={() => !completed && openEdit(a)}
|
||
role="button"
|
||
tabindex={completed ? -1 : 0}
|
||
>
|
||
<div class="todo-admin-body">
|
||
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
|
||
<div class="todo-admin-meta">
|
||
{#if a.type === 'emoji'}
|
||
<span class="todo-admin-type">{a.emoji || '🎉'} celebration</span>
|
||
{:else if a.type === 'money'}
|
||
<span class="todo-admin-type">£{Number(a.value).toFixed(2)} cash</span>
|
||
{:else}
|
||
<span class="todo-admin-type"
|
||
><span class="badge badge-pts">{a.value} pts</span></span
|
||
>
|
||
{/if}
|
||
{#if a.completeBy}
|
||
<span class="todo-admin-deadline"
|
||
>due {formatHumanDate(a.completeBy)}</span
|
||
>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{#if completed}
|
||
<span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
|
||
>
|
||
{:else}
|
||
<button
|
||
class="del-btn"
|
||
title="Remove todo"
|
||
onclick={async (e) => {
|
||
e.stopPropagation();
|
||
const s = page.data.session as any;
|
||
if (!s) return;
|
||
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
|
||
});
|
||
}}>×</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}
|
||
>+ Add a todo</button
|
||
>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- CHORES accordion wrapper -->
|
||
<div
|
||
class="accordion-wrapper accordion-chores"
|
||
class:open={accordionState[m.id]?.chores ?? true}
|
||
>
|
||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||
<span class="accordion-title">
|
||
<span class="accordion-icon">{@html choresIcon}</span>
|
||
<span class="accordion-label">Chores</span>
|
||
</span>
|
||
<span class="accordion-chevron"
|
||
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||
>
|
||
</button>
|
||
<span class="accordion-count" style="background:{m.color}"
|
||
>{assignedForMember(m.id).length}</span
|
||
>
|
||
{#if accordionState[m.id]?.chores ?? true}
|
||
<div class="accordion-body">
|
||
{#each assignedForMember(m.id) as a}
|
||
{@const tName = templateName(a.templateId)}
|
||
<div
|
||
class="card assigned"
|
||
onclick={() => openEdit(a)}
|
||
role="button"
|
||
tabindex="0"
|
||
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(
|
||
a.color
|
||
)}"
|
||
>
|
||
<div class="card-body">
|
||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||
<strong>{a.customName || tName}</strong>
|
||
<span class="badge">{a.frequency}</span>
|
||
{#each a.seasonIds || [] as sid}
|
||
<span
|
||
class="season-dot"
|
||
title={seasonName(sid)}
|
||
style="background:{seasonColor(sid)}"
|
||
></span>
|
||
{/each}
|
||
{#if (a.seasonIds || []).length === 0}
|
||
<span class="badge season muted">All year</span>
|
||
{/if}
|
||
</div>
|
||
<div class="card-value">
|
||
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
|
||
</div>
|
||
<button
|
||
class="del-btn"
|
||
title="Remove assignment"
|
||
onclick={async (e) => {
|
||
e.stopPropagation();
|
||
const s = page.data.session as any;
|
||
if (!s) return;
|
||
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
|
||
method: 'DELETE',
|
||
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
|
||
});
|
||
}}>×</button
|
||
>
|
||
</div>
|
||
{/each}
|
||
{#if assignedForMember(m.id).length === 0}
|
||
<p class="empty">Drop a chore here</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{#if members.length < 3}
|
||
{#each Array(3 - members.length) as _}
|
||
<div class="column column-placeholder"></div>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
</Card>
|
||
</CardGrid>
|
||
|
||
<!-- Season pill selector (reused by assign + edit modals) -->
|
||
{#snippet seasonPills(list: Season[] = [], selected: string[] = [], set: (v: string[]) => void)}
|
||
<div class="season-pills" role="radiogroup" aria-label="Seasons">
|
||
<button
|
||
type="button"
|
||
class="season-pill"
|
||
class:selected={selected.length === 0}
|
||
style="--pill:#9ca3af"
|
||
role="radio"
|
||
aria-checked={selected.length === 0}
|
||
onclick={() => set([])}>Any</button
|
||
>
|
||
{#each list as s}
|
||
<button
|
||
type="button"
|
||
class="season-pill"
|
||
class:selected={selected[0] === s.id}
|
||
style="--pill:{s.color}"
|
||
role="radio"
|
||
aria-checked={selected[0] === s.id}
|
||
onclick={() => set([s.id])}>{s.name}</button
|
||
>
|
||
{/each}
|
||
</div>
|
||
{/snippet}
|
||
|
||
<!-- Assign from Template Modal -->
|
||
{#if assignModal}
|
||
<div
|
||
class="overlay"
|
||
onclick={(e) => {
|
||
if (e.target === e.currentTarget) assignModal = null;
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||
<h3>Assign: {assignModal.t.name}</h3>
|
||
<p class="hint">
|
||
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)
|
||
?.name}'s chores. Amend, then Assign.
|
||
</p>
|
||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||
<label
|
||
>Description <input
|
||
bind:value={assignDescription}
|
||
placeholder="Optional description"
|
||
/></label
|
||
>
|
||
<div class="color-field">
|
||
<span class="field-label">Colour</span>
|
||
<div class="color-swatches">
|
||
{#each TEMPLATE_COLORS as c}
|
||
<button
|
||
type="button"
|
||
class="swatch"
|
||
class:selected={assignColor === c.value}
|
||
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
|
||
'transparent'
|
||
? '#e5e7eb'
|
||
: c.value}"
|
||
title={c.label}
|
||
onclick={() => (assignColor = c.value)}
|
||
></button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
<label class="icon-row"
|
||
><span class="icon-preview"
|
||
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
|
||
>
|
||
<select bind:value={assignIcon}>
|
||
<option value="">None</option>
|
||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Frequency
|
||
<select bind:value={assignFreq}>
|
||
<option value="daily">Daily</option>
|
||
<option value="weekly">Weekly</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Type
|
||
<select bind:value={assignType}>
|
||
<option value="points">Points</option>
|
||
<option value="money">Money</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Value
|
||
<input type="number" step="any" bind:value={assignValue} />
|
||
</label>
|
||
<div class="season-field">
|
||
<span class="field-label">Seasons</span>
|
||
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
|
||
<button type="button" onclick={assignChore}>Assign</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Edit Assignment Modal -->
|
||
{#if showEditModal && editingAssignment}
|
||
<div
|
||
class="overlay"
|
||
onclick={(e) => {
|
||
if (e.target === e.currentTarget) closeEdit();
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||
<h3>
|
||
{#if editingAssignment.isTodo}
|
||
Edit Todo: {editingAssignment.customName || 'Todo'}
|
||
{:else}
|
||
Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}
|
||
{/if}
|
||
</h3>
|
||
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
|
||
<input name="id" type="hidden" value={editingAssignment.id} />
|
||
<input name="isTodo" type="hidden" value={editingAssignment.isTodo ? '1' : '0'} />
|
||
<label>
|
||
Name
|
||
<input name="customName" bind:value={editCustomName} placeholder="Name" />
|
||
</label>
|
||
<label
|
||
>Description
|
||
<input
|
||
name="description"
|
||
bind:value={editDescription}
|
||
placeholder="Optional description"
|
||
/></label
|
||
>
|
||
|
||
{#if editingAssignment.isTodo}
|
||
<label>
|
||
Type
|
||
<select name="type" bind:value={editType}>
|
||
<option value="points">Points</option>
|
||
<option value="money">Cash</option>
|
||
<option value="emoji">Celebration</option>
|
||
</select>
|
||
</label>
|
||
{#if editType === 'points'}
|
||
<label>
|
||
Points value
|
||
<input name="value" type="number" bind:value={editValue} required />
|
||
</label>
|
||
{:else if editType === 'money'}
|
||
<label>
|
||
Cash amount (£)
|
||
<input
|
||
name="value"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
bind:value={editValue}
|
||
required
|
||
/>
|
||
</label>
|
||
{:else}
|
||
<input type="hidden" name="emoji" value={editEmoji} />
|
||
<div class="emoji-field">
|
||
<span class="emoji-preview">{editEmoji}</span>
|
||
<button
|
||
type="button"
|
||
class="link-btn"
|
||
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
|
||
>
|
||
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
||
</button>
|
||
</div>
|
||
{#if showTodoEmojiPicker}
|
||
<div class="emoji-grid">
|
||
{#each EMOJI_CHOICES as e}
|
||
<button
|
||
type="button"
|
||
class="emoji-opt"
|
||
class:selected={e === editEmoji}
|
||
onclick={() => {
|
||
editEmoji = e;
|
||
showTodoEmojiPicker = false;
|
||
}}>{e}</button
|
||
>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
<label>
|
||
Start date
|
||
<input name="startDate" type="date" bind:value={editTodoStartDate} />
|
||
</label>
|
||
<label>
|
||
Complete by
|
||
<input name="completeBy" type="date" bind:value={editTodoCompleteBy} />
|
||
</label>
|
||
{:else}
|
||
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
|
||
<div class="color-field">
|
||
<span class="field-label">Colour</span>
|
||
<div class="color-swatches">
|
||
{#each TEMPLATE_COLORS as c}
|
||
<button
|
||
type="button"
|
||
class="swatch"
|
||
class:selected={editColor === c.value}
|
||
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
|
||
'transparent'
|
||
? '#e5e7eb'
|
||
: c.value}"
|
||
title={c.label}
|
||
onclick={() => (editColor = c.value)}
|
||
></button>
|
||
{/each}
|
||
</div>
|
||
<input type="hidden" name="color" value={editColor} />
|
||
</div>
|
||
<label class="icon-row"
|
||
><span class="icon-preview"
|
||
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
|
||
>
|
||
<select name="icon" bind:value={editIcon}>
|
||
<option value="">None</option>
|
||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Frequency
|
||
<select name="frequency" bind:value={editFreq}>
|
||
<option value="daily">Daily</option>
|
||
<option value="weekly">Weekly</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Type
|
||
<select name="type" bind:value={editType}>
|
||
<option value="points">Points</option>
|
||
<option value="money">Money</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Value
|
||
<input name="value" type="number" step="any" bind:value={editValue} required />
|
||
</label>
|
||
<div class="season-field">
|
||
<span class="field-label">Seasons</span>
|
||
{@render seasonPills(seasons, editSeasonIds, (v: string[]) => (editSeasonIds = v))}
|
||
</div>
|
||
{/if}
|
||
<div class="modal-actions">
|
||
<button type="button" onclick={closeEdit}>Cancel</button>
|
||
<button type="submit">Save</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Add Todo Modal -->
|
||
{#if showTodoModal && todoMemberId}
|
||
<div
|
||
class="overlay"
|
||
onclick={(e) => {
|
||
if (e.target === e.currentTarget) showTodoModal = false;
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
|
||
<form
|
||
method="POST"
|
||
action="?/createTodo"
|
||
use:enhance={() => {
|
||
return async ({ result }: any) => {
|
||
if (result.type === 'success') {
|
||
showTodoModal = false;
|
||
todoMemberId = '';
|
||
}
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="memberId" value={todoMemberId} />
|
||
<input
|
||
type="hidden"
|
||
name="startDate"
|
||
value={todoStart === 'now' ? new Date().toISOString().slice(0, 10) : ''}
|
||
/>
|
||
<input
|
||
type="hidden"
|
||
name="completeBy"
|
||
value={todoCompleteBy === 'custom' ? todoCustomDate : ''}
|
||
/>
|
||
<label>
|
||
Name
|
||
<input name="name" bind:value={todoName} required placeholder="What needs doing?" />
|
||
</label>
|
||
<label>
|
||
Type
|
||
<select name="type" bind:value={todoType}>
|
||
<option value="points">Points</option>
|
||
<option value="money">Cash</option>
|
||
<option value="emoji">Celebration</option>
|
||
</select>
|
||
</label>
|
||
{#if todoType === 'points'}
|
||
<label>
|
||
Points value
|
||
<input name="value" type="number" bind:value={todoValue} required />
|
||
</label>
|
||
{:else if todoType === 'money'}
|
||
<label>
|
||
Cash amount (£)
|
||
<input
|
||
name="value"
|
||
type="number"
|
||
step="0.01"
|
||
min="0.01"
|
||
bind:value={todoValue}
|
||
required
|
||
/>
|
||
</label>
|
||
{:else}
|
||
<input type="hidden" name="emoji" value={todoEmoji} />
|
||
<div class="emoji-field">
|
||
<span class="emoji-preview">{todoEmoji}</span>
|
||
<button
|
||
type="button"
|
||
class="link-btn"
|
||
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
|
||
>
|
||
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
|
||
</button>
|
||
</div>
|
||
{#if showTodoEmojiPicker}
|
||
<div class="emoji-grid">
|
||
{#each EMOJI_CHOICES as e}
|
||
<button
|
||
type="button"
|
||
class="emoji-opt"
|
||
class:selected={e === todoEmoji}
|
||
onclick={() => {
|
||
todoEmoji = e;
|
||
showTodoEmojiPicker = false;
|
||
}}>{e}</button
|
||
>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
<label>
|
||
Starts
|
||
<select name="todoStart" bind:value={todoStart}>
|
||
<option value="now">Now</option>
|
||
<option value="next-week">Next week</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Complete by
|
||
<select name="todoCompleteBy" bind:value={todoCompleteBy}>
|
||
<option value="next-week">Next week</option>
|
||
<option value="custom">Custom date</option>
|
||
</select>
|
||
</label>
|
||
{#if todoCompleteBy === 'custom'}
|
||
<label>
|
||
Due date
|
||
<input name="customDate" type="date" bind:value={todoCustomDate} required />
|
||
</label>
|
||
{/if}
|
||
<div class="modal-actions">
|
||
<button type="button" onclick={() => (showTodoModal = false)}>Cancel</button>
|
||
<button type="submit">Add Todo</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.error {
|
||
color: #dc2626;
|
||
padding: 0.5rem;
|
||
background: #fef2f2;
|
||
border-radius: 4px;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
/* ── Template section (top, like bonuses) ── */
|
||
.template-section {
|
||
margin-bottom: 0;
|
||
}
|
||
.template-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.template-header h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
}
|
||
.template-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.template-list .card.template {
|
||
flex: 0 1 auto;
|
||
min-width: 150px;
|
||
}
|
||
.template-list .card.template .card-body {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.template-list {
|
||
flex-direction: column;
|
||
}
|
||
.template-list .card.template {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||
.kanban {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0.75rem;
|
||
min-width: 450px;
|
||
}
|
||
.column {
|
||
border: 1px solid #e5e7eb;
|
||
border-top: 4px solid #6366f1;
|
||
border-radius: 12px;
|
||
padding: 0.75rem;
|
||
background: #fafbff;
|
||
min-height: 200px;
|
||
}
|
||
.column h3 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.95rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.dot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.empty {
|
||
color: #9ca3af;
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
padding: 1.5rem;
|
||
}
|
||
.add-inline {
|
||
display: block;
|
||
width: 100%;
|
||
margin-bottom: 0.35rem;
|
||
padding: 0.45rem;
|
||
background: rgba(99, 102, 241, 0.04);
|
||
border: 1px dashed #c7d2fe;
|
||
border-radius: 8px;
|
||
color: #6366f1;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
border-color 0.15s;
|
||
text-align: center;
|
||
}
|
||
.template-section > .add-inline {
|
||
margin-bottom: 0;
|
||
}
|
||
.accordion-add {
|
||
margin-bottom: 0;
|
||
margin-top: 0.35rem;
|
||
background: rgba(255, 255, 255, 0.25);
|
||
border-color: rgba(255, 255, 255, 0.4);
|
||
color: #fff;
|
||
}
|
||
.accordion-add:hover {
|
||
background: rgba(255, 255, 255, 0.35);
|
||
border-color: rgba(255, 255, 255, 0.6);
|
||
color: #fff;
|
||
}
|
||
.add-inline:hover {
|
||
border-color: #6366f1;
|
||
background: #eef2ff;
|
||
}
|
||
|
||
.card {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-left: 4px solid #8b5cf6;
|
||
border-radius: 8px;
|
||
padding: 0.5rem 0.75rem;
|
||
margin-bottom: 0.4rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
position: relative;
|
||
transition:
|
||
transform 0.15s,
|
||
box-shadow 0.15s,
|
||
border-color 0.15s;
|
||
}
|
||
.card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.card-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.card-body strong {
|
||
font-size: 0.85rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
color: #1f2937;
|
||
}
|
||
.card-value {
|
||
font-size: 0.8rem;
|
||
color: #6b7280;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.badge {
|
||
font-size: 0.7rem;
|
||
background: #e0e7ff;
|
||
color: #4338ca;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
margin-right: 4px;
|
||
}
|
||
.badge.type {
|
||
background: #fce7f3;
|
||
color: #be185d;
|
||
}
|
||
.card-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.edit-btn,
|
||
.del-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #9ca3af;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
padding: 2px 6px;
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
border-radius: 4px;
|
||
transition:
|
||
color 0.15s,
|
||
background 0.15s;
|
||
}
|
||
.edit-btn:hover {
|
||
color: #6366f1;
|
||
background: #eef2ff;
|
||
}
|
||
.del-btn:hover {
|
||
color: #dc2626;
|
||
background: #fef2f2;
|
||
}
|
||
.hint {
|
||
color: #6b7280;
|
||
font-size: 0.85rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.season-filter {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.kanban-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.season-filter label {
|
||
font-size: 0.8rem;
|
||
color: #6b7280;
|
||
font-weight: 500;
|
||
}
|
||
.season-filter select {
|
||
padding: 0.3rem 0.5rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
font-size: 0.8rem;
|
||
background: #fff;
|
||
}
|
||
.badge.season {
|
||
background: transparent;
|
||
border: 1px solid #0369a1;
|
||
color: #0369a1;
|
||
}
|
||
.badge.season.muted {
|
||
color: #9ca3af;
|
||
border-color: #e5e7eb;
|
||
}
|
||
.season-dot {
|
||
display: inline-block;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
flex: none;
|
||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
|
||
}
|
||
.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;
|
||
}
|
||
|
||
.color-field,
|
||
.season-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.field-label {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
}
|
||
.modal label.icon-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.modal label.icon-row select {
|
||
flex: 1;
|
||
}
|
||
.icon-preview {
|
||
display: inline-flex;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
background: #f3f4f6;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
.color-swatches {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
.swatch {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
border: 2px solid #e5e7eb;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.swatch.selected {
|
||
outline: 2px solid #111827;
|
||
outline-offset: 1px;
|
||
}
|
||
.season-pills {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
.season-pill {
|
||
padding: 0.3rem 0.7rem;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--pill, #e5e7eb);
|
||
background: transparent;
|
||
color: #374151;
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
transition: all 0.12s ease;
|
||
}
|
||
.season-pill.selected {
|
||
background: var(--pill, #8b5cf6);
|
||
color: #fff;
|
||
border-color: var(--pill, #8b5cf6);
|
||
}
|
||
|
||
.template {
|
||
cursor: grab;
|
||
border-left: 4px solid #8b5cf6;
|
||
}
|
||
.template:active {
|
||
cursor: grabbing;
|
||
opacity: 0.8;
|
||
}
|
||
.assigned {
|
||
cursor: pointer;
|
||
border-left: 4px solid #10b981;
|
||
}
|
||
|
||
.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 1rem;
|
||
}
|
||
.modal label {
|
||
display: block;
|
||
margin-bottom: 0.75rem;
|
||
font-size: 0.9rem;
|
||
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: 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: #6366f1;
|
||
color: white;
|
||
}
|
||
.modal-actions button.delete-btn {
|
||
background: #dc2626;
|
||
color: white;
|
||
}
|
||
|
||
/* ── Accordion wrappers ── */
|
||
.accordion-wrapper {
|
||
position: relative;
|
||
margin: 0.75rem 0 0.3rem;
|
||
}
|
||
.accordion-wrapper:first-child {
|
||
margin-top: 1.1rem;
|
||
}
|
||
.accordion-head {
|
||
width: 100%;
|
||
padding: 0.55rem 0.85rem;
|
||
border: none;
|
||
border-left: 4px solid rgba(0, 0, 0, 0.2);
|
||
background: var(--mc, #6366f1);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
transition: filter 0.15s;
|
||
}
|
||
.accordion-wrapper.accordion-todos .accordion-head {
|
||
background: oklch(from var(--mc, #6366f1) calc(l + 0.08) c h);
|
||
}
|
||
.accordion-wrapper.open .accordion-head {
|
||
border-radius: 10px 10px 0 0;
|
||
}
|
||
.accordion-head:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
.accordion-body {
|
||
padding: 0.5rem 0.65rem 0.65rem;
|
||
background: oklch(from var(--mc, #6366f1) calc(l - 0.1) c h);
|
||
border-radius: 0 0 10px 10px;
|
||
}
|
||
.accordion-wrapper.accordion-todos .accordion-body {
|
||
background: oklch(from var(--mc, #6366f1) calc(l - 0.02) c h);
|
||
}
|
||
.accordion-chevron {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
opacity: 0.8;
|
||
}
|
||
.accordion-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
.accordion-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.accordion-label {
|
||
font-size: 0.82rem;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
.accordion-count {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
min-width: 22px;
|
||
height: 22px;
|
||
padding: 0 6px;
|
||
border-radius: 999px;
|
||
background: #6366f1;
|
||
color: #fff;
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||
z-index: 2;
|
||
}
|
||
|
||
/* ── Todo admin cards ── */
|
||
.todo-admin-card {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-left: 4px solid #3b82f6;
|
||
border-radius: 8px;
|
||
padding: 0.45rem 0.65rem;
|
||
margin-bottom: 0.35rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
transition:
|
||
transform 0.15s,
|
||
box-shadow 0.15s,
|
||
opacity 0.2s;
|
||
}
|
||
.todo-admin-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.todo-admin-card.todo-completed {
|
||
opacity: 0.55;
|
||
cursor: default;
|
||
background: #f0fdf4;
|
||
border-left-color: #10b981;
|
||
}
|
||
.todo-admin-card.todo-completed:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
.todo-blue-bg {
|
||
background: #eff6ff;
|
||
border-left-color: #3b82f6;
|
||
}
|
||
.todo-green-bg {
|
||
background: #f0fdf4;
|
||
border-left-color: #22c55e;
|
||
}
|
||
.todo-orange-bg {
|
||
background: #fff7ed;
|
||
border-left-color: #f97316;
|
||
}
|
||
.todo-red-bg {
|
||
background: #fef2f2;
|
||
border-left-color: #ef4444;
|
||
}
|
||
.todo-black-bg {
|
||
background: #f9fafb;
|
||
border-left-color: #1f2937;
|
||
}
|
||
|
||
.todo-admin-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.todo-admin-name {
|
||
display: block;
|
||
font-size: 0.85rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.todo-admin-meta {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
font-size: 0.7rem;
|
||
margin-top: 0.15rem;
|
||
}
|
||
.emoji-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.emoji-preview {
|
||
font-size: 1.4rem;
|
||
}
|
||
.emoji-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
padding: 0.4rem;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.emoji-opt {
|
||
font-size: 1.2rem;
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 6px;
|
||
padding: 0.15rem 0.3rem;
|
||
cursor: pointer;
|
||
}
|
||
.emoji-opt:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
.emoji-opt.selected {
|
||
border-color: #6366f1;
|
||
background: #eef2ff;
|
||
}
|
||
.todo-admin-type {
|
||
color: #6b7280;
|
||
font-weight: 600;
|
||
}
|
||
.todo-admin-deadline {
|
||
color: #f59e0b;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.todo-completed-badge {
|
||
font-size: 0.65rem;
|
||
font-weight: 700;
|
||
color: #059669;
|
||
background: #d1fae5;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
}
|
||
.todo-completed-badge :global(svg) {
|
||
width: 0.95em;
|
||
height: 0.95em;
|
||
}
|
||
|
||
/* ── Points badge ── */
|
||
.badge-pts {
|
||
background: #e0e7ff;
|
||
color: #4338ca;
|
||
font-size: 0.68rem;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ── Placeholder column ── */
|
||
.column-placeholder {
|
||
border-top-color: #e5e7eb !important;
|
||
background: repeating-linear-gradient(
|
||
45deg,
|
||
transparent,
|
||
transparent 8px,
|
||
rgba(0, 0, 0, 0.02) 8px,
|
||
rgba(0, 0, 0, 0.02) 16px
|
||
);
|
||
border-style: dashed;
|
||
opacity: 0.5;
|
||
min-height: 200px;
|
||
}
|
||
</style>
|