You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

241 lines
6.6 KiB
Svelte

<script lang="ts">
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Card from "$lib/ui/layout/Card.svelte";
import { onMount } from "svelte";
import { slide, fade } from "svelte/transition";
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
type Task = {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
group_id?: string | null;
assignees?: string[];
};
export let data: {
userId: string | null;
groups: GroupItem[];
itemsByGroup: Record<string, Task[]>;
unassignedFirst?: boolean;
};
const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
}
function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
return sp.toString();
}
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
let collapsed: Record<string, boolean> = {};
function hasTasks(groupId: string): boolean {
const arr = itemsByGroup[groupId] || [];
return Array.isArray(arr) && arr.length > 0;
}
function defaultCollapsedFor(groupId: string): boolean {
// Por defecto, colapsado si no tiene tareas abiertas
return !hasTasks(groupId);
}
function isOpen(groupId: string): boolean {
const v = collapsed[groupId];
if (typeof v === "boolean") return !v;
return !defaultCollapsedFor(groupId);
}
function saveCollapsed() {
try {
const currentIds = new Set(groups.map((g) => g.id));
const pruned: Record<string, boolean> = {};
for (const id of Object.keys(collapsed)) {
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
}
localStorage.setItem(storageKey, JSON.stringify(pruned));
} catch {}
}
function handleToggle(groupId: string, e: Event) {
const open = (e.currentTarget as HTMLDetailsElement).open;
collapsed = { ...collapsed, [groupId]: !open };
saveCollapsed();
}
onMount(() => {
try {
const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : {};
const map: Record<string, boolean> = {};
const currentIds = new Set(groups.map((g) => g.id));
for (const g of groups) {
map[g.id] =
typeof saved?.[g.id] === "boolean"
? !!saved[g.id]
: defaultCollapsedFor(g.id);
}
// Limpieza de claves obsoletas en storage
const cleaned: Record<string, boolean> = {};
for (const k of Object.keys(saved || {})) {
if (currentIds.has(k)) cleaned[k] = !!saved[k];
}
collapsed = map;
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
} catch {
// si falla, dejamos los defaults (basados en tareas)
collapsed = {};
}
});
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
const { id, action, patch } = detail;
const arr = itemsByGroup[groupId] || [];
const idx = arr.findIndex((t) => t.id === id);
if (action === 'complete') {
if (idx >= 0) {
maintainScrollWhile(() => {
arr.splice(idx, 1);
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
});
}
return;
}
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
}
}
</script>
<svelte:head>
<title>Grupos</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
{#if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1 class="title">Grupos</h1>
<div class="toolbar">
<label class="toggle">
<input
type="checkbox"
checked={!!data.unassignedFirst}
on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`;
}}
/>
Sin responsable primero
</label>
</div>
{#each groups as g (g.id)}
<details
class="group"
open={isOpen(g.id)}
on:toggle={(e) => handleToggle(g.id, e)}
>
<summary class="group-header">
<span class="name">{g.name ?? g.id}</span>
<span class="counts">
<span class="badge">tareas: {g.counts.open}</span>
<span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span>
</span>
</summary>
{#if isOpen(g.id)}
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }} in:fade={{ duration: 120 }} out:fade={{ duration: 120 }}>
<Card>
<ul class="list">
{#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem
id={t.id}
description={t.description}
due_date={t.due_date}
display_code={t.display_code}
assignees={t.assignees || []}
currentUserId={data.userId}
groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/>
{/each}
</ul>
</Card>
</div>
{/if}
</details>
{/each}
{/if}
<style>
.title {
margin-bottom: 0.75rem;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.toggle {
display: inline-flex;
gap: 6px;
align-items: center;
}
.group {
margin: 0.5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.group-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.group-header .name {
font-weight: 600;
}
.counts {
display: inline-flex;
gap: 0.5rem;
}
.badge {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 12px;
}
.badge.warn {
border-color: var(--color-warning);
}
.list {
margin: 0;
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
list-style: none;
}
</style>