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