Files
famdone/frontend/src/routes/[fam]/[username]/chores/+page.svelte
T
2026-08-25 14:30:26 +01:00

1240 lines
33 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { formatHumanDate } from '$lib/format';
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 showCreateModal = $state(false);
let showEditModal = $state(false);
let showEditTemplateModal = $state(false);
let showTodoModal = $state(false);
let editingAssignment = $state<AssignedChore | null>(null);
let editingTemplate = $state<ChoreTemplate | null>(null);
let editMemberId = $state('');
let draggedTemplateId = $state<string | null>(null);
let todoMemberId = $state('');
let createName = $state('');
let createFreq = $state('daily');
let createType = $state('points');
let createValue = $state(10);
// Todo form state
let todoName = $state('');
let todoType = $state<'points' | 'emoji'>('points');
let todoValue = $state(10);
let todoStart = $state<'now' | 'next-week'>('now');
let todoCompleteBy = $state<'next-week' | 'custom'>('next-week');
let todoCustomDate = $state('');
let editFreq = $state('daily');
let editType = $state('points');
let editValue = $state(10);
let editCustomName = $state('');
let editTodoCompleteBy = $state('');
let editTodoStartDate = $state('');
let editTplName = $state('');
let editTplFreq = $state('daily');
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 = $derived(
famStore.initialized
? (famStore.assigned as AssignedChore[])
: (data.assigned as AssignedChore[]) || []
);
let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []);
let completions = $state((data.completions as Completion[]) || []);
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 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;
if (!tid) return;
draggedTemplateId = null;
const s = page.data.session as any;
if (!s) return;
const def = getDefault(tid);
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',
headers: {
'Content-Type': 'application/json',
'x-session-famid': s.famId,
'x-session-userid': s.userId
},
body: JSON.stringify(body)
});
}
// 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;
todoStart = 'now';
todoCompleteBy = 'custom';
todoCustomDate = defaultCompleteByDate();
showTodoModal = true;
}
// Sort todos by completeBy (soonest first), then by startDate
function sortedTodosForMember(memberId: string): AssignedChore[] {
return todosForMember(memberId).sort((a, b) => {
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 || '');
});
}
// 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 || '';
editSeasonIds = a.seasonIds ? [...a.seasonIds] : [];
editTodoCompleteBy = a.completeBy || '';
editTodoStartDate = a.startDate || '';
showEditModal = true;
}
function closeEdit() {
showEditModal = false;
editingAssignment = null;
}
function openEditTemplate(t: ChoreTemplate) {
editingTemplate = t;
editTplName = t.name;
editTplFreq = t.defaultFrequency;
editTplType = t.defaultType;
editTplValue = t.defaultValue;
showEditTemplateModal = true;
}
function closeEditTemplate() {
showEditTemplateModal = false;
editingTemplate = null;
}
function enhanceCreate() {
return async ({ result }: any) => {
if (result.type === 'success') {
showCreateModal = false;
const rec = result.data?.record;
if (rec) {
templates = [rec as ChoreTemplate, ...templates];
}
}
};
}
function enhanceUpdateAssigned() {
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 = editValue;
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();
};
}
function enhanceUpdateTemplate() {
return async ({ result, formData }: any) => {
if (result.type === 'success' && editingTemplate) {
const tid = editingTemplate!.id;
const idx = templates.findIndex((t: any) => t.id === tid);
if (idx !== -1) {
templates[idx] = {
...templates[idx],
name: editTplName,
defaultFrequency: editTplFreq,
defaultType: editTplType,
defaultValue: editTplValue
} as ChoreTemplate;
assigned = assigned.map((a: any) =>
a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a
) as AssignedChore[];
closeEditTemplate();
}
}
};
}
</script>
<ViewHeader title="Chore Assignment" hero />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<CardGrid>
<!-- Templates Section (at top, like bonuses) -->
<Card cols={3}>
<div class="template-section">
<div class="template-header">
<h3>Templates — Drag to assign</h3>
<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>
</div>
<div class="template-list">
{#each templates as t}
<div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)}>
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
<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 class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"
>✎</button
>
</div>
</div>
{/each}
{#if templates.length === 0}
<p class="empty">No templates yet</p>
{/if}
</div>
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
</div>
</Card>
<!-- Member Chore Assignment Kanban (3-column grid) -->
<Card cols={3} scrollX>
<div class="kanban">
{#each members as m}
<div
class="column member-col"
style="border-top-color: {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">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
{#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body">
{#each sortedTodosForMember(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">🎯 emoji</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">✅ 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">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
{#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">
<div class="card-body">
<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} 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>
<!-- Create Template Modal -->
{#if showCreateModal}
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Chore Template</h3>
<form method="POST" action="?/createTemplate" use:enhance={enhanceCreate}>
<label>
Name
<input name="name" bind:value={createName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={createFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={createType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={createValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={() => (showCreateModal = false)}>Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Template Modal -->
{#if showEditTemplateModal && editingTemplate}
<div class="overlay" onclick={closeEditTemplate} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit Template: {editingTemplate.name}</h3>
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
<form id="updateTplForm" method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
<input name="id" type="hidden" value={editingTemplate.id} />
<label>
Name
<input name="name" bind:value={editTplName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={editTplFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={editTplType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
</label>
</form>
<div class="modal-actions">
<button type="button" onclick={closeEditTemplate}>Cancel</button>
<form
method="POST"
action="?/deleteTemplate"
use:enhance={() => {
return async ({ result, formData }) => {
if (result.type === 'success') {
const id = formData.get('id');
templates = templates.filter((t) => t.id !== id) as ChoreTemplate[];
closeEditTemplate();
}
};
}}
>
<input type="hidden" name="id" value={editingTemplate.id} />
<button
type="submit"
class="delete-btn"
onclick={(e) => {
if (!confirm('Delete this template? This cannot be undone.')) {
e.preventDefault();
}
}}>Delete</button
>
</form>
<button type="submit" form="updateTplForm">Save</button>
</div>
</div>
</div>
{/if}
<!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment}
<div class="overlay" onclick={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>
{#if editingAssignment.isTodo}
<label>
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="emoji">Emoji</option>
</select>
</label>
{#if editType === 'points'}
<label>
Points value
<input name="value" type="number" bind:value={editValue} required />
</label>
{/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(',')} />
<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>
<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>
{/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={() => (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="emoji">Emoji</option>
</select>
</label>
{#if todoType === 'points'}
<label>
Points value
<input name="value" type="number" bind:value={todoValue} required />
</label>
{/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: grid;
gap: 0.5rem;
margin-bottom: 0.75rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) {
.template-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.template-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ── 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-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;
}
.card-body strong {
display: block;
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;
}
.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: #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: 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 {
background: #6366f1;
border-radius: 10px;
margin: 0.75rem 0 0.3rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
}
.accordion-wrapper.accordion-todos {
background: #2e8b57;
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
}
.accordion-head {
width: 100%;
padding: 0.55rem 0.85rem;
border: none;
border-left: 4px solid rgba(0, 0, 0, 0.2);
background: transparent;
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
transition: filter 0.15s;
}
.accordion-head:hover {
filter: brightness(1.1);
}
.accordion-body {
padding: 0.5rem 0.65rem 0.65rem;
background: rgba(0, 0, 0, 0.08);
}
.accordion-chevron {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.8;
}
/* ── 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;
}
.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;
}
/* ── 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>