cambia algunos iconos para que sin responsable sea 🙅 y que las badges en la web estén centradas aunque la row sea de dos lineas
parent
226e1bc01f
commit
a24e591cb4
@ -1,240 +1,251 @@
|
||||
<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 } 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] };
|
||||
}
|
||||
}
|
||||
import TaskItem from "$lib/ui/data/TaskItem.svelte";
|
||||
import Card from "$lib/ui/layout/Card.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { slide } 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" />
|
||||
<title>Grupos</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<p>No perteneces a ningún grupo permitido.</p>
|
||||
<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 }}>
|
||||
<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}
|
||||
<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 }}>
|
||||
<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;
|
||||
}
|
||||
.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;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue