feat(t-paliad-110): add shared EventsPage component + bucket-aware backend tweaks
PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.
Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
(Fristen / Termine / Beides), the 5-card summary row (Überfällig
conditional + 4 universal cards), the union filter row, and the unified
table that renders a discriminated row per type. Two header CTAs ("Neue
Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
(PR-3 will inject defaultType from the Go handler), derives the rest from
?type query param. Card click sets status filter; the events endpoint
takes care of bucket-aware appointment-side date windowing so both rails
stay in sync in Beides mode. Hide-on-uniform pattern applied per column
(rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
from one renderEvents(currentPath) so each output gets the right Sidebar
highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
the new deadlines.summary.later / deadlines.filter.later pair for the
Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
matching events-table--hide-* column hiders, .events-row-type-chip
styling, .event-type-chip-row spacing.
Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
window from a bucket-style status (today/this_week/next_week/later) so
card clicks filter both rails consistently. Overdue/Completed exclude
appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
any caller-supplied from/to.
go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
This commit is contained in:
@@ -16,6 +16,7 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderDeadlines } from "./src/deadlines";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
|
||||
@@ -240,6 +241,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
|
||||
@@ -349,6 +351,11 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "deadlines.html"), renderDeadlines());
|
||||
// t-paliad-110 — shared EventsPage shell. Two HTML outputs (events-deadlines
|
||||
// / events-appointments) so each route gets a Sidebar/BottomNav highlighted
|
||||
// for the matching nav entry; the Go handler injects defaultType at runtime.
|
||||
await Bun.write(join(DIST, "events-deadlines.html"), renderEvents("/deadlines"));
|
||||
await Bun.write(join(DIST, "events-appointments.html"), renderEvents("/appointments"));
|
||||
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
|
||||
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
|
||||
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
|
||||
|
||||
725
frontend/src/client/events.ts
Normal file
725
frontend/src/client/events.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypeMultiSelectFilter,
|
||||
fetchEventTypes,
|
||||
eventTypeLabel,
|
||||
type EventType,
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
|
||||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||||
// /appointments off the same shell — the route handler injects
|
||||
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
|
||||
// on first paint and we read it here to set the initial chip + filter
|
||||
// visibility. The chip toggle on the page lets the user widen to
|
||||
// "Beides" without a route change; the URL gets `?type=` for state.
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_EVENTS__?: {
|
||||
defaultType?: EventTypeChoice;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type EventTypeChoice = "deadline" | "appointment" | "all";
|
||||
|
||||
interface EventListItem {
|
||||
type: "deadline" | "appointment";
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_reference?: string;
|
||||
project_title?: string;
|
||||
project_type?: string;
|
||||
|
||||
// deadline-only
|
||||
due_date?: string;
|
||||
status?: string;
|
||||
completed_at?: string;
|
||||
source?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
}
|
||||
|
||||
interface EventSummary {
|
||||
deadlines?: {
|
||||
overdue: number;
|
||||
today: number;
|
||||
this_week: number;
|
||||
next_week: number;
|
||||
later: number;
|
||||
completed: number;
|
||||
total: number;
|
||||
};
|
||||
appointments?: {
|
||||
today: number;
|
||||
this_week: number;
|
||||
next_week: number;
|
||||
later: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
const PERSONAL = "__personal__";
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
let statusFilter = "pending";
|
||||
let projectFilter = "";
|
||||
let appointmentTypeFilter = "";
|
||||
let allItems: EventListItem[] = [];
|
||||
let allProjects: Project[] = [];
|
||||
let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function defaultType(): EventTypeChoice {
|
||||
const injected = window.__PALIAD_EVENTS__?.defaultType;
|
||||
if (injected === "deadline" || injected === "appointment" || injected === "all") {
|
||||
return injected;
|
||||
}
|
||||
// Fallback: derive from path so a stale handler bundle still routes correctly.
|
||||
if (window.location.pathname.startsWith("/appointments")) return "appointment";
|
||||
return "deadline";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function setCount(id: string, n: number) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(n);
|
||||
}
|
||||
|
||||
function fmtDateOnly(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso.length === 10 ? iso + "T00:00:00" : iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// formatDateCell yields the per-row date label. Deadlines render as
|
||||
// "31.08.2026" (date-only). Appointments render as "15.09.2026 09:00",
|
||||
// extended with "–11:00" when end_at is on the same day, or
|
||||
// "→ 16.09.2026 11:00" when end_at spills into a later day.
|
||||
function formatDateCell(item: EventListItem): string {
|
||||
if (item.type === "deadline") {
|
||||
return fmtDateOnly(item.due_date ?? item.event_date);
|
||||
}
|
||||
if (!item.start_at) return "";
|
||||
const start = new Date(item.start_at);
|
||||
const startLabel = fmtDateTime(item.start_at);
|
||||
if (!item.end_at) return startLabel;
|
||||
const end = new Date(item.end_at);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
if (sameDay) {
|
||||
return `${startLabel}–${fmtTime(item.end_at)}`;
|
||||
}
|
||||
return `${startLabel} → ${fmtDateTime(item.end_at)}`;
|
||||
}
|
||||
|
||||
function urgencyClass(item: EventListItem): string {
|
||||
if (item.type === "deadline") {
|
||||
if (item.status === "completed") return "frist-urgency-done";
|
||||
}
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(item.event_date);
|
||||
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
||||
if (diffDays < 0) return "frist-urgency-overdue";
|
||||
if (diffDays <= 7) return "frist-urgency-soon";
|
||||
return "frist-urgency-later";
|
||||
}
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
return "—";
|
||||
}
|
||||
|
||||
function eventTypeDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
const ids = item.event_type_ids ?? [];
|
||||
if (ids.length === 0) return "—";
|
||||
const labels: string[] = [];
|
||||
for (const id of ids) {
|
||||
const et = eventTypeByID.get(id);
|
||||
if (et) labels.push(eventTypeLabel(et));
|
||||
}
|
||||
if (labels.length === 0) return "—";
|
||||
return labels.map((l) => `<span class="entity-event-type-pill">${esc(l)}</span>`).join(" ");
|
||||
}
|
||||
|
||||
function rowTypeChip(item: EventListItem): string {
|
||||
const label = item.type === "deadline" ? t("events.row.type.deadline") : t("events.row.type.appointment");
|
||||
const cls = item.type === "deadline" ? "events-row-type-frist" : "events-row-type-termin";
|
||||
return `<span class="events-row-type-chip ${cls}">${esc(label)}</span>`;
|
||||
}
|
||||
|
||||
function canReopen(): boolean {
|
||||
return !!me && me.global_role === "global_admin";
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEventTypes() {
|
||||
try {
|
||||
const types = await fetchEventTypes();
|
||||
eventTypeByID = new Map(types.map((et) => [et.id, et]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", currentType);
|
||||
if (projectFilter && projectFilter !== PERSONAL) {
|
||||
params.set("project_id", projectFilter);
|
||||
}
|
||||
const resp = await fetch(`/api/events/summary?${params.toString()}`);
|
||||
if (!resp.ok) return;
|
||||
const sum: EventSummary = await resp.json();
|
||||
|
||||
// 4 universal cards: pick the deadline value when present, otherwise
|
||||
// the appointment value. In Beides mode, sum both rails.
|
||||
const today = (sum.deadlines?.today ?? 0) + (sum.appointments?.today ?? 0);
|
||||
const thisWeek = (sum.deadlines?.this_week ?? 0) + (sum.appointments?.this_week ?? 0);
|
||||
const nextWeek = (sum.deadlines?.next_week ?? 0) + (sum.appointments?.next_week ?? 0);
|
||||
const later = (sum.deadlines?.later ?? 0) + (sum.appointments?.later ?? 0);
|
||||
|
||||
setCount("events-sum-today", today);
|
||||
setCount("events-sum-week", thisWeek);
|
||||
setCount("events-sum-next-week", nextWeek);
|
||||
setCount("events-sum-later", later);
|
||||
|
||||
// Überfällig is deadline-only and conditional. Hide on appointment view
|
||||
// OR when the count is zero. Alarm-style when count > 0 on a view that
|
||||
// includes deadlines (mirrors t-paliad-105 / t-paliad-106 behaviour).
|
||||
const overdue = sum.deadlines?.overdue ?? 0;
|
||||
setCount("events-sum-overdue", overdue);
|
||||
applyOverdueState(overdue);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function applyOverdueState(overdue: number) {
|
||||
const card = document.querySelector<HTMLElement>('.frist-summary-card[data-bucket="overdue"]');
|
||||
if (!card) return;
|
||||
const includeDeadlines = currentType === "deadline" || currentType === "all";
|
||||
const hidden = !includeDeadlines || overdue === 0;
|
||||
card.classList.toggle("frist-card-overdue-hidden", hidden);
|
||||
card.classList.toggle("frist-card-alarm", !hidden && overdue > 0);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const unavailable = document.getElementById("events-unavailable")!;
|
||||
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", currentType);
|
||||
if (currentType !== "appointment" && statusFilter) {
|
||||
// Status filter is deadline-only. In Beides mode the status still
|
||||
// maps onto the deadline side and the EventService handles bucket-
|
||||
// aware appointment filtering for Heute/Diese Woche/etc.
|
||||
params.set("status", statusFilter);
|
||||
}
|
||||
if (projectFilter && projectFilter !== PERSONAL) {
|
||||
params.set("project_id", projectFilter);
|
||||
}
|
||||
if (currentType !== "appointment") {
|
||||
const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||||
if (eventTypeQuery) params.set("event_type", eventTypeQuery);
|
||||
}
|
||||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||||
params.set("type_filter", appointmentTypeFilter);
|
||||
}
|
||||
const resp = await fetch(`/api/events?${params.toString()}`);
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
document.getElementById("events-empty")!.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
let data: EventListItem[] = await resp.json();
|
||||
// "Personal only" is a UI shorthand for project_id IS NULL on the
|
||||
// appointment side. Filter client-side because the backend doesn't
|
||||
// expose a NULL-id query (matches current /appointments behaviour).
|
||||
if (projectFilter === PERSONAL) {
|
||||
data = data.filter((x) => x.type === "appointment" && !x.project_id);
|
||||
}
|
||||
allItems = data;
|
||||
loadedOK = true;
|
||||
render();
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("events-body")!;
|
||||
const empty = document.getElementById("events-empty")!;
|
||||
const emptyFiltered = document.getElementById("events-empty-filtered")!;
|
||||
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
|
||||
const table = document.getElementById("events-table");
|
||||
|
||||
if (allItems.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
if (isFilterPristine()) {
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
|
||||
const showReopen = canReopen();
|
||||
tbody.innerHTML = allItems.map((item) => renderRow(item, showReopen)).join("");
|
||||
|
||||
// Hide-on-uniform pattern (t-paliad-073, generalised in t-088). When all
|
||||
// visible rows agree on a column we drop the column entirely so empty
|
||||
// cells don't add noise. Each column has its own data signal so we can
|
||||
// toggle independently.
|
||||
const anyDeadline = allItems.some((x) => x.type === "deadline");
|
||||
const anyAppointment = allItems.some((x) => x.type === "appointment");
|
||||
const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0);
|
||||
const anyLocation = allItems.some((x) => !!x.location);
|
||||
const anyAppointmentType = allItems.some((x) => !!x.appointment_type);
|
||||
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_name || x.rule_name_en));
|
||||
const anyStatus = anyDeadline; // appointments don't carry a status column
|
||||
|
||||
table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment);
|
||||
table?.classList.toggle("events-table--hide-rule", !anyRule);
|
||||
table?.classList.toggle("entity-table--hide-event-type", !anyEventType);
|
||||
table?.classList.toggle("events-table--hide-location", !anyLocation);
|
||||
table?.classList.toggle("events-table--hide-appointment-type", !anyAppointmentType);
|
||||
table?.classList.toggle("entity-table--hide-status", !anyStatus);
|
||||
|
||||
wireRowHandlers(tbody);
|
||||
}
|
||||
|
||||
function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
const urgency = urgencyClass(item);
|
||||
const dateLabel = formatDateCell(item);
|
||||
const ruleLabel = item.type === "deadline" ? ruleDisplay(item) : "";
|
||||
const eventTypeCell = item.type === "deadline" ? eventTypeDisplay(item) : "";
|
||||
|
||||
const projectCell = item.project_id
|
||||
? `<a class="entity-ref-link" href="/projects/${esc(item.project_id)}">${esc(item.project_reference ?? "")}</a>`
|
||||
+ `<span class="frist-project-title" title="${esc(item.project_title ?? "")}">${esc(item.project_title ?? "")}</span>`
|
||||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||||
|
||||
let checkCell = "";
|
||||
let titleClass = "";
|
||||
let statusCell = "";
|
||||
if (item.type === "deadline") {
|
||||
const isDone = item.status === "completed";
|
||||
titleClass = isDone ? "frist-title-done" : "";
|
||||
const statusLabel = tDyn(`deadlines.status.${item.status ?? "pending"}`) || (item.status ?? "");
|
||||
statusCell = `<span class="entity-status-chip entity-status-${esc(item.status ?? "")}">${esc(statusLabel)}</span>`;
|
||||
const reopenLabel = esc(t("deadlines.action.reopen"));
|
||||
if (isDone) {
|
||||
checkCell = showReopen
|
||||
? `<button type="button" class="frist-reopen-btn" aria-label="${reopenLabel}" title="${reopenLabel}">↻</button>`
|
||||
: `<input type="checkbox" class="frist-complete-cb" checked disabled aria-label="${esc(t("deadlines.complete.action"))}" />`;
|
||||
} else {
|
||||
checkCell = `<input type="checkbox" class="frist-complete-cb" aria-label="${esc(t("deadlines.complete.action"))}" />`;
|
||||
}
|
||||
} else {
|
||||
const typeClass = item.appointment_type ? `termin-type-${item.appointment_type}` : "";
|
||||
checkCell = `<span class="termin-dot ${typeClass}"></span>`;
|
||||
}
|
||||
|
||||
const locationCell = item.location ? esc(item.location) : "—";
|
||||
const appointmentTypeCell = item.appointment_type
|
||||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||||
: "—";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
<td class="frist-col-check">${checkCell}</td>
|
||||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}</td>
|
||||
<td class="frist-col-project">${projectCell}</td>
|
||||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||||
<td class="events-col-location">${locationCell}</td>
|
||||
<td class="events-col-appointment-type">${appointmentTypeCell}</td>
|
||||
<td class="entity-col-status">${statusCell}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
const type = row.dataset.type!;
|
||||
row.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(".frist-complete-cb") ||
|
||||
target.closest(".frist-reopen-btn") ||
|
||||
target.closest("a")
|
||||
) return;
|
||||
window.location.href = type === "deadline" ? `/deadlines/${id}` : `/appointments/${id}`;
|
||||
});
|
||||
|
||||
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
||||
if (cb && !cb.disabled) {
|
||||
cb.addEventListener("change", async () => {
|
||||
if (!cb.checked) return;
|
||||
cb.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
} else {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
const reopenBtn = row.querySelector<HTMLButtonElement>(".frist-reopen-btn");
|
||||
if (reopenBtn) {
|
||||
reopenBtn.addEventListener("click", async () => {
|
||||
reopenBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${id}/reopen`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
} else {
|
||||
reopenBtn.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
reopenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isFilterPristine(): boolean {
|
||||
return (
|
||||
statusFilter === "pending" &&
|
||||
!projectFilter &&
|
||||
!appointmentTypeFilter &&
|
||||
(eventTypeFilter?.toQueryValue() ?? "") === ""
|
||||
);
|
||||
}
|
||||
|
||||
function applyTypeVisibility() {
|
||||
// Page-level chrome — heading, subtitle, actions, type chips' active state.
|
||||
const heading = document.getElementById("events-heading");
|
||||
const subtitle = document.getElementById("events-subtitle");
|
||||
const title = document.getElementById("events-title");
|
||||
const isAppointment = currentType === "appointment";
|
||||
const isDeadline = currentType === "deadline";
|
||||
const isAll = currentType === "all";
|
||||
|
||||
if (heading) heading.textContent = isAppointment ? t("appointments.list.heading") : t("deadlines.list.heading");
|
||||
if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle");
|
||||
if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title");
|
||||
|
||||
// Calendar links: hide the irrelevant one in single-type modes.
|
||||
toggleDisplay("events-action-deadline-cal", isDeadline || isAll, "inline-flex");
|
||||
toggleDisplay("events-action-appointment-cal", isAppointment || isAll, "inline-flex");
|
||||
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
|
||||
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
|
||||
|
||||
// Empty-state CTAs.
|
||||
toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex");
|
||||
toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex");
|
||||
|
||||
// Status filter is deadline-only.
|
||||
toggleFilterPair("events-filter-status", !isAppointment);
|
||||
// Event-type multi-select also deadline-only (appointments have no event_types).
|
||||
toggleFilterPair("events-filter-event-type", !isAppointment, "events-filter-event-type-label");
|
||||
toggleDisplay("events-filter-event-type-panel", !isAppointment, "block");
|
||||
// Termin-Typ is appointment-only.
|
||||
toggleFilterPair("events-filter-appointment-type", !isDeadline);
|
||||
|
||||
// "Personal only" project option only makes sense with appointments in scope.
|
||||
const projectSel = document.getElementById("events-filter-project") as HTMLSelectElement | null;
|
||||
if (projectSel) {
|
||||
const personalOpt = projectSel.querySelector<HTMLOptionElement>('option[value="__personal__"]');
|
||||
if (personalOpt) {
|
||||
personalOpt.hidden = isDeadline;
|
||||
personalOpt.disabled = isDeadline;
|
||||
if (isDeadline && projectFilter === PERSONAL) {
|
||||
projectFilter = "";
|
||||
projectSel.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update chip active state.
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||||
b.classList.toggle("agenda-chip-active", b.dataset.eventType === currentType);
|
||||
});
|
||||
|
||||
// Refresh overdue card visibility on type change.
|
||||
applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10));
|
||||
}
|
||||
|
||||
function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.display = show ? displayWhenShown : "none";
|
||||
}
|
||||
|
||||
// toggleFilterPair shows/hides a control AND its sibling label so the
|
||||
// filter row collapses cleanly when a filter doesn't apply for the
|
||||
// current type.
|
||||
function toggleFilterPair(controlID: string, show: boolean, labelID?: string) {
|
||||
toggleDisplay(controlID, show, "");
|
||||
// Default: derive label by id="events-filter-X" → label htmlFor="events-filter-X"
|
||||
const labelTarget = labelID ?? "";
|
||||
if (labelTarget) {
|
||||
toggleDisplay(labelTarget, show, "");
|
||||
return;
|
||||
}
|
||||
const label = document.querySelector<HTMLLabelElement>(`label[for="${controlID}"]`);
|
||||
if (label) label.style.display = show ? "" : "none";
|
||||
}
|
||||
|
||||
function syncURLParams() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("type");
|
||||
url.searchParams.delete("status");
|
||||
url.searchParams.delete("project_id");
|
||||
url.searchParams.delete("event_type");
|
||||
url.searchParams.delete("type_filter");
|
||||
// Default type per route comes from window.__PALIAD_EVENTS__; only
|
||||
// surface in the URL when the user opted into something else.
|
||||
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
|
||||
if (currentType !== "appointment" && statusFilter && statusFilter !== "pending") {
|
||||
url.searchParams.set("status", statusFilter);
|
||||
}
|
||||
if (projectFilter) url.searchParams.set("project_id", projectFilter);
|
||||
if (currentType !== "appointment") {
|
||||
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||||
if (eventQuery) url.searchParams.set("event_type", eventQuery);
|
||||
}
|
||||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||||
url.searchParams.set("type_filter", appointmentTypeFilter);
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function populateProjectFilter() {
|
||||
const sel = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||||
const options: string[] = [
|
||||
`<option value="">${esc(t("deadlines.filter.akte.all"))}</option>`,
|
||||
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
|
||||
];
|
||||
for (const a of allProjects) {
|
||||
options.push(
|
||||
`<option value="${esc(a.id)}">${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
if (projectFilter) sel.value = projectFilter;
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const params = urlParams();
|
||||
currentType = (params.get("type") as EventTypeChoice) || defaultType();
|
||||
if (params.has("status")) statusFilter = params.get("status")!;
|
||||
if (params.has("project_id")) projectFilter = params.get("project_id")!;
|
||||
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
|
||||
|
||||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||||
const project = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||||
const appointmentType = document.getElementById("events-filter-appointment-type") as HTMLSelectElement;
|
||||
const eventTrigger = document.getElementById("events-filter-event-type") as HTMLButtonElement;
|
||||
const eventPanel = document.getElementById("events-filter-event-type-panel") as HTMLElement;
|
||||
|
||||
status.value = statusFilter;
|
||||
appointmentType.value = appointmentTypeFilter;
|
||||
|
||||
let initialEventIDs: string[] = [];
|
||||
let initialIncludeUntyped = false;
|
||||
const initialEventRaw = params.get("event_type") ?? "";
|
||||
if (initialEventRaw) {
|
||||
for (const tok of initialEventRaw.split(",")) {
|
||||
const tt = tok.trim();
|
||||
if (!tt) continue;
|
||||
if (tt === "none") initialIncludeUntyped = true;
|
||||
else initialEventIDs.push(tt);
|
||||
}
|
||||
}
|
||||
|
||||
status.addEventListener("change", async () => {
|
||||
statusFilter = status.value;
|
||||
syncURLParams();
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
});
|
||||
project.addEventListener("change", async () => {
|
||||
projectFilter = project.value;
|
||||
syncURLParams();
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
});
|
||||
appointmentType.addEventListener("change", async () => {
|
||||
appointmentTypeFilter = appointmentType.value;
|
||||
syncURLParams();
|
||||
await loadList();
|
||||
});
|
||||
|
||||
if (eventTrigger && eventPanel) {
|
||||
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
|
||||
initialIDs: initialEventIDs,
|
||||
initialIncludeUntyped: initialIncludeUntyped,
|
||||
onChange: async () => {
|
||||
syncURLParams();
|
||||
await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||||
b.addEventListener("click", async () => {
|
||||
const next = b.dataset.eventType as EventTypeChoice;
|
||||
if (next === currentType) return;
|
||||
currentType = next;
|
||||
applyTypeVisibility();
|
||||
syncURLParams();
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
||||
card.addEventListener("click", async () => {
|
||||
const bucket = card.dataset.bucket!;
|
||||
// Map the card to the matching status filter; the events endpoint
|
||||
// handles bucket-aware appointment filtering internally so both
|
||||
// rails reflect the click consistently in Beides mode.
|
||||
statusFilter = bucket;
|
||||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||||
if (status) status.value = bucket;
|
||||
syncURLParams();
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initSummaryCards();
|
||||
applyTypeVisibility();
|
||||
onLangChange(() => {
|
||||
applyTypeVisibility();
|
||||
render();
|
||||
});
|
||||
await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
|
||||
populateProjectFilter();
|
||||
await Promise.all([loadList(), loadSummary()]);
|
||||
});
|
||||
@@ -526,6 +526,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.summary.thisweek": "Diese Woche",
|
||||
"deadlines.summary.nextweek": "N\u00e4chste Woche",
|
||||
"deadlines.summary.completed": "Erledigt",
|
||||
"deadlines.summary.later": "Später",
|
||||
"deadlines.filter.status": "Status",
|
||||
"deadlines.filter.akte": "Projekt",
|
||||
"deadlines.filter.all": "Alle (offen & erledigt)",
|
||||
@@ -535,6 +536,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.filter.thisweek": "Diese Woche",
|
||||
"deadlines.filter.nextweek": "N\u00e4chste Woche",
|
||||
"deadlines.filter.completed": "Erledigt",
|
||||
"deadlines.filter.later": "Später",
|
||||
"deadlines.filter.akte.all": "Alle Projekte",
|
||||
"deadlines.col.due": "F\u00e4llig",
|
||||
"deadlines.col.title": "Titel",
|
||||
@@ -1104,6 +1106,24 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
|
||||
"appointments.kalender.list": "Listenansicht",
|
||||
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
|
||||
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
|
||||
// these keys cover the shared chrome (chip toggle, type chip per row,
|
||||
// generic Datum column header, etc).
|
||||
"events.toggle.deadline": "Fristen",
|
||||
"events.toggle.appointment": "Termine",
|
||||
"events.toggle.all": "Beides",
|
||||
"events.summary.later": "Sp\u00e4ter",
|
||||
"events.col.date": "Datum",
|
||||
"events.col.location": "Ort",
|
||||
"events.col.appointment_type": "Termin-Typ",
|
||||
"events.row.type.deadline": "Frist",
|
||||
"events.row.type.appointment": "Termin",
|
||||
"events.empty.title": "Keine Eintr\u00e4ge vorhanden",
|
||||
"events.empty.hint": "Sobald Fristen oder Termine angelegt werden, erscheinen sie hier.",
|
||||
"events.empty.filtered": "Keine Eintr\u00e4ge mit diesen Filtern.",
|
||||
"events.unavailable": "Termin- und Fristenverwaltung zurzeit nicht verf\u00fcgbar \u2014 bitte Administrator kontaktieren.",
|
||||
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV-Synchronisation",
|
||||
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
|
||||
@@ -2004,6 +2024,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.summary.thisweek": "This week",
|
||||
"deadlines.summary.nextweek": "Next week",
|
||||
"deadlines.summary.completed": "Done",
|
||||
"deadlines.summary.later": "Later",
|
||||
"deadlines.filter.status": "Status",
|
||||
"deadlines.filter.akte": "Matter",
|
||||
"deadlines.filter.all": "All (open & completed)",
|
||||
@@ -2013,6 +2034,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.filter.thisweek": "This week",
|
||||
"deadlines.filter.nextweek": "Next week",
|
||||
"deadlines.filter.completed": "Done",
|
||||
"deadlines.filter.later": "Later",
|
||||
"deadlines.filter.akte.all": "All matters",
|
||||
"deadlines.col.due": "Due",
|
||||
"deadlines.col.title": "Title",
|
||||
@@ -2578,6 +2600,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
|
||||
"appointments.kalender.list": "List view",
|
||||
"appointments.kalender.empty": "No appointments in the selected period.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
"events.toggle.appointment": "Appointments",
|
||||
"events.toggle.all": "Both",
|
||||
"events.summary.later": "Later",
|
||||
"events.col.date": "Date",
|
||||
"events.col.location": "Location",
|
||||
"events.col.appointment_type": "Appointment type",
|
||||
"events.row.type.deadline": "Deadline",
|
||||
"events.row.type.appointment": "Appointment",
|
||||
"events.empty.title": "Nothing yet",
|
||||
"events.empty.hint": "Once deadlines or appointments are added, they appear here.",
|
||||
"events.empty.filtered": "No entries match these filters.",
|
||||
"events.unavailable": "Deadline / appointment management currently unavailable \u2014 please contact your administrator.",
|
||||
"caldav.title": "CalDAV sync \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV sync",
|
||||
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",
|
||||
|
||||
226
frontend/src/events.tsx
Normal file
226
frontend/src/events.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// EventsPage is the shared shell rendered on both /deadlines and
|
||||
// /appointments (t-paliad-110). The current default type ("deadline" /
|
||||
// "appointment") is injected by the Go handler via
|
||||
// `window.__PALIAD_EVENTS__` and consumed by client/events.ts on init,
|
||||
// which then drives the heading, the bucket cards, and the filter row
|
||||
// without a re-mount.
|
||||
//
|
||||
// `currentPath` is what powers the Sidebar / BottomNav active highlight —
|
||||
// "/deadlines" for the Fristen entry, "/appointments" for the Termine
|
||||
// entry. Both routes share the rest of the markup verbatim.
|
||||
export function renderEvents(currentPath: "/deadlines" | "/appointments"): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title id="events-title" data-i18n="deadlines.list.title">Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath={currentPath} />
|
||||
<BottomNav currentPath={currentPath} />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 id="events-heading" data-i18n="deadlines.list.heading">Fristen</h1>
|
||||
<p
|
||||
className="tool-subtitle"
|
||||
id="events-subtitle"
|
||||
data-i18n="deadlines.list.subtitle"
|
||||
>
|
||||
Persistente Fristen für Ihre Akten. Überfällig, heute, diese Woche, nächste Woche — auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fristen-header-actions" id="events-actions">
|
||||
<a
|
||||
href="/deadlines/calendar"
|
||||
className="btn-secondary"
|
||||
id="events-action-deadline-cal"
|
||||
data-i18n="deadlines.list.calendar"
|
||||
>
|
||||
Kalenderansicht
|
||||
</a>
|
||||
<a
|
||||
href="/appointments/calendar"
|
||||
className="btn-secondary"
|
||||
id="events-action-appointment-cal"
|
||||
data-i18n="appointments.list.calendar"
|
||||
>
|
||||
Kalenderansicht
|
||||
</a>
|
||||
<a
|
||||
href="/deadlines/new"
|
||||
className="btn-primary btn-cta-lime"
|
||||
id="events-action-new-deadline"
|
||||
data-i18n="deadlines.list.new"
|
||||
>
|
||||
Neue Frist
|
||||
</a>
|
||||
<a
|
||||
href="/appointments/new"
|
||||
className="btn-primary btn-cta-lime"
|
||||
id="events-action-new-appointment"
|
||||
data-i18n="appointments.list.new"
|
||||
>
|
||||
Neuer Termin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-chip-row event-type-chip-row" role="tablist" id="events-type-chips">
|
||||
<button
|
||||
type="button"
|
||||
className="agenda-chip"
|
||||
data-event-type="deadline"
|
||||
data-i18n="events.toggle.deadline"
|
||||
role="tab"
|
||||
>
|
||||
Fristen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agenda-chip"
|
||||
data-event-type="appointment"
|
||||
data-i18n="events.toggle.appointment"
|
||||
role="tab"
|
||||
>
|
||||
Termine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agenda-chip"
|
||||
data-event-type="all"
|
||||
data-i18n="events.toggle.all"
|
||||
role="tab"
|
||||
>
|
||||
Beides
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="frist-summary-cards" id="events-summary">
|
||||
<button type="button" className="frist-summary-card frist-card-overdue" data-bucket="overdue">
|
||||
<span className="frist-summary-dot" />
|
||||
<span className="frist-summary-count" id="events-sum-overdue">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.overdue">Überfällig</span>
|
||||
</button>
|
||||
<button type="button" className="frist-summary-card frist-card-today" data-bucket="today">
|
||||
<span className="frist-summary-dot" />
|
||||
<span className="frist-summary-count" id="events-sum-today">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.today">Heute</span>
|
||||
</button>
|
||||
<button type="button" className="frist-summary-card frist-card-week" data-bucket="this_week">
|
||||
<span className="frist-summary-dot" />
|
||||
<span className="frist-summary-count" id="events-sum-week">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.thisweek">Diese Woche</span>
|
||||
</button>
|
||||
<button type="button" className="frist-summary-card frist-card-next-week" data-bucket="next_week">
|
||||
<span className="frist-summary-dot" />
|
||||
<span className="frist-summary-count" id="events-sum-next-week">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.nextweek">Nächste Woche</span>
|
||||
</button>
|
||||
<button type="button" className="frist-summary-card frist-card-later" data-bucket="later">
|
||||
<span className="frist-summary-dot" />
|
||||
<span className="frist-summary-count" id="events-sum-later">0</span>
|
||||
<span className="frist-summary-label" data-i18n="deadlines.summary.later">Später</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="entity-controls">
|
||||
<div className="filter-row">
|
||||
<label className="filter-label" htmlFor="events-filter-status" data-i18n="deadlines.filter.status">Status</label>
|
||||
<select id="events-filter-status" className="entity-select">
|
||||
<option value="all" data-i18n="deadlines.filter.all">Alle (offen & erledigt)</option>
|
||||
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
|
||||
<option value="overdue" data-i18n="deadlines.filter.overdue">Überfällig</option>
|
||||
<option value="today" data-i18n="deadlines.filter.today">Heute</option>
|
||||
<option value="this_week" data-i18n="deadlines.filter.thisweek">Diese Woche</option>
|
||||
<option value="next_week" data-i18n="deadlines.filter.nextweek">Nächste Woche</option>
|
||||
<option value="later" data-i18n="deadlines.filter.later">Später</option>
|
||||
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
|
||||
</select>
|
||||
|
||||
<label className="filter-label" htmlFor="events-filter-project" data-i18n="deadlines.filter.akte">Projekt</label>
|
||||
<select id="events-filter-project" className="entity-select">
|
||||
<option value="" data-i18n="deadlines.filter.akte.all">Alle Projekte</option>
|
||||
<option value="__personal__" data-i18n="appointments.filter.akte.personal">Nur persönliche</option>
|
||||
</select>
|
||||
|
||||
<label className="filter-label" htmlFor="events-filter-event-type" id="events-filter-event-type-label" data-i18n="deadlines.filter.event_type">Typ</label>
|
||||
<button type="button" id="events-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
|
||||
<div id="events-filter-event-type-panel" className="multi-panel" hidden />
|
||||
|
||||
<label className="filter-label" htmlFor="events-filter-appointment-type" id="events-filter-appointment-type-label" data-i18n="appointments.filter.type">Typ</label>
|
||||
<select id="events-filter-appointment-type" className="entity-select">
|
||||
<option value="" data-i18n="appointments.filter.type.all">Alle Typen</option>
|
||||
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
|
||||
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
|
||||
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
|
||||
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="events-unavailable" className="entity-unavailable" style="display:none">
|
||||
<p data-i18n="events.unavailable">
|
||||
Termin- und Fristenverwaltung zurzeit nicht verfügbar — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table fristen-table events-table" id="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th className="events-col-row-type" />
|
||||
<th data-i18n="events.col.date">Datum</th>
|
||||
<th data-i18n="deadlines.col.title">Titel</th>
|
||||
<th data-i18n="deadlines.col.akte">Projekt</th>
|
||||
<th className="events-col-rule" data-i18n="deadlines.col.rule">Regel</th>
|
||||
<th className="entity-col-event-type" data-i18n="deadlines.col.event_type">Typ</th>
|
||||
<th className="events-col-location" data-i18n="events.col.location">Ort</th>
|
||||
<th className="events-col-appointment-type" data-i18n="events.col.appointment_type">Termin-Typ</th>
|
||||
<th className="entity-col-status" data-i18n="deadlines.col.status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="events-empty" style="display:none">
|
||||
<h2 data-i18n="events.empty.title">Keine Einträge vorhanden</h2>
|
||||
<p data-i18n="events.empty.hint">
|
||||
Sobald Fristen oder Termine angelegt werden, erscheinen sie hier.
|
||||
</p>
|
||||
<a href="/deadlines/new" className="btn-primary btn-cta-lime" id="events-empty-cta-deadline" data-i18n="deadlines.list.new">Neue Frist</a>
|
||||
<a href="/appointments/new" className="btn-primary btn-cta-lime" id="events-empty-cta-appointment" data-i18n="appointments.list.new">Neuer Termin</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty entity-empty-filtered" id="events-empty-filtered" style="display:none">
|
||||
<p data-i18n="events.empty.filtered">Keine Einträge mit diesen Filtern.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/events.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -618,6 +618,7 @@ export type I18nKey =
|
||||
| "deadlines.filter.all"
|
||||
| "deadlines.filter.completed"
|
||||
| "deadlines.filter.event_type"
|
||||
| "deadlines.filter.later"
|
||||
| "deadlines.filter.nextweek"
|
||||
| "deadlines.filter.overdue"
|
||||
| "deadlines.filter.pending"
|
||||
@@ -678,6 +679,7 @@ export type I18nKey =
|
||||
| "deadlines.step3"
|
||||
| "deadlines.subtitle"
|
||||
| "deadlines.summary.completed"
|
||||
| "deadlines.summary.later"
|
||||
| "deadlines.summary.nextweek"
|
||||
| "deadlines.summary.overdue"
|
||||
| "deadlines.summary.thisweek"
|
||||
@@ -825,6 +827,19 @@ export type I18nKey =
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "events.col.appointment_type"
|
||||
| "events.col.date"
|
||||
| "events.col.location"
|
||||
| "events.empty.filtered"
|
||||
| "events.empty.hint"
|
||||
| "events.empty.title"
|
||||
| "events.row.type.appointment"
|
||||
| "events.row.type.deadline"
|
||||
| "events.summary.later"
|
||||
| "events.toggle.all"
|
||||
| "events.toggle.appointment"
|
||||
| "events.toggle.deadline"
|
||||
| "events.unavailable"
|
||||
| "footer.text"
|
||||
| "gebuehren.col.courtfee"
|
||||
| "gebuehren.col.fee"
|
||||
|
||||
@@ -5145,6 +5145,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
--bucket-today: #c2410c;
|
||||
--bucket-week: #a16207;
|
||||
--bucket-next-week: #16a34a;
|
||||
--bucket-later: #1d4ed8;
|
||||
--bucket-done: #6b7280;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
@@ -5152,6 +5153,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
--bucket-today: #fb923c;
|
||||
--bucket-week: #fbbf24;
|
||||
--bucket-next-week: #4ade80;
|
||||
--bucket-later: #60a5fa;
|
||||
--bucket-done: #9ca3af;
|
||||
}
|
||||
|
||||
@@ -5217,6 +5219,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.frist-card-week { border-left-color: var(--bucket-week); color: var(--bucket-week); }
|
||||
.frist-card-next-week { border-left-color: var(--bucket-next-week); color: var(--bucket-next-week); }
|
||||
.frist-card-upcoming { border-left-color: var(--bucket-next-week); color: var(--bucket-next-week); }
|
||||
.frist-card-later { border-left-color: var(--bucket-later); color: var(--bucket-later); }
|
||||
.frist-card-completed { border-left-color: var(--bucket-done); color: var(--bucket-done); }
|
||||
|
||||
.fristen-table .frist-col-check {
|
||||
@@ -8656,6 +8659,41 @@ dialog.quick-add-sheet::backdrop {
|
||||
/* Hide-on-uniform: when no row has any event_type, hide the column. */
|
||||
.entity-table--hide-event-type .entity-col-event-type { display: none; }
|
||||
|
||||
/* t-paliad-110 — column hiders for the unified EventsPage. The shared
|
||||
table renders the union of deadline + appointment columns; each
|
||||
`events-table--hide-*` modifier collapses one column when no visible
|
||||
row supplies the matching field, mirroring the t-073 pattern. */
|
||||
.events-table--hide-row-type .events-col-row-type { display: none; }
|
||||
.events-table--hide-rule .events-col-rule { display: none; }
|
||||
.events-table--hide-location .events-col-location { display: none; }
|
||||
.events-table--hide-appointment-type .events-col-appointment-type { display: none; }
|
||||
|
||||
/* Type chip rendered in the leftmost data column when the table mixes
|
||||
deadlines + appointments — at-a-glance type signal that survives
|
||||
monochrome printing. */
|
||||
.events-row-type-chip {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.events-row-type-frist { color: var(--bucket-today); border-color: var(--bucket-today); }
|
||||
.events-row-type-termin { color: var(--bucket-next-week); border-color: var(--bucket-next-week); }
|
||||
|
||||
/* Type chip toggle row (Fristen / Termine / Beides) above the bucket
|
||||
cards — reuses the agenda-chip visuals so users get muscle-memory
|
||||
parity with /agenda. */
|
||||
.event-type-chip-row {
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
|
||||
.event-type-add-modal {
|
||||
width: 28rem;
|
||||
|
||||
@@ -84,6 +84,7 @@ const (
|
||||
DeadlineFilterToday DeadlineStatusFilter = "today"
|
||||
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
|
||||
DeadlineFilterNextWeek DeadlineStatusFilter = "next_week"
|
||||
DeadlineFilterLater DeadlineStatusFilter = "later"
|
||||
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
|
||||
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
|
||||
DeadlineFilterPending DeadlineStatusFilter = "pending"
|
||||
@@ -143,6 +144,12 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
conds = append(conds, `f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after`)
|
||||
args["next_monday"] = b.nextMonday
|
||||
args["week_after"] = b.weekAfter
|
||||
case DeadlineFilterLater:
|
||||
// "Später" — pending deadlines past next Sunday (t-paliad-110).
|
||||
// The card on /deadlines clicks through to this filter; the dropdown
|
||||
// also exposes it as the always-future-of-bucketed-window option.
|
||||
conds = append(conds, `f.status = 'pending' AND f.due_date >= :week_after`)
|
||||
args["week_after"] = b.weekAfter
|
||||
case DeadlineFilterUpcoming:
|
||||
// Back-compat: "upcoming" used to mean "anything pending past this week".
|
||||
// Kept so legacy bookmarks / third-party links don't 4xx; the new UI
|
||||
|
||||
@@ -126,18 +126,33 @@ func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID,
|
||||
}
|
||||
|
||||
if wantAppointments {
|
||||
af := AppointmentListFilter{
|
||||
ProjectID: filter.ProjectID,
|
||||
From: filter.From,
|
||||
To: filter.To,
|
||||
Type: filter.AppointmentType,
|
||||
}
|
||||
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out = append(out, projectAppointment(r))
|
||||
// Status is a deadline-only filter, but it doubles as the bucket
|
||||
// click-through target on the unified events page. So when the
|
||||
// caller set a bucket-style status (today / this_week / next_week
|
||||
// / later), apply the matching date window to the appointment side
|
||||
// too — clicking "Heute" then shows today's deadlines AND today's
|
||||
// appointments. For overdue/completed (no appointment analogue),
|
||||
// skip the appointment query entirely.
|
||||
if shouldExcludeAppointmentsForStatus(filter.Status) {
|
||||
// no-op
|
||||
} else {
|
||||
af := AppointmentListFilter{
|
||||
ProjectID: filter.ProjectID,
|
||||
From: filter.From,
|
||||
To: filter.To,
|
||||
Type: filter.AppointmentType,
|
||||
}
|
||||
bounds := computeDeadlineBucketBounds(time.Now().UTC())
|
||||
from, to := bucketAppointmentWindow(filter.Status, bounds)
|
||||
af.From = pickLater(af.From, from)
|
||||
af.To = pickEarlier(af.To, to)
|
||||
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out = append(out, projectAppointment(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +224,70 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
||||
}
|
||||
}
|
||||
|
||||
// shouldExcludeAppointmentsForStatus returns true for deadline-status
|
||||
// values that have no appointment analogue (overdue, completed). When
|
||||
// the user sets one of those, the appointment rail collapses to empty.
|
||||
func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
|
||||
switch status {
|
||||
case DeadlineFilterOverdue, DeadlineFilterCompleted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bucketAppointmentWindow returns the [from, to) date window that
|
||||
// matches a bucket-style deadline status — used to filter the
|
||||
// appointment side when the user clicks a card on the unified events
|
||||
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
|
||||
// upcoming / "" / overdue / completed — those are handled separately).
|
||||
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
|
||||
switch status {
|
||||
case DeadlineFilterToday:
|
||||
t := b.tomorrow
|
||||
return &b.today, &t
|
||||
case DeadlineFilterThisWeek:
|
||||
t := b.nextMonday
|
||||
return &b.tomorrow, &t
|
||||
case DeadlineFilterNextWeek:
|
||||
t := b.weekAfter
|
||||
return &b.nextMonday, &t
|
||||
case DeadlineFilterLater:
|
||||
return &b.weekAfter, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// pickLater returns whichever of the two times is later (nil propagates
|
||||
// the non-nil value; both nil → nil). Used to intersect bucket-derived
|
||||
// windows with caller-supplied from filters.
|
||||
func pickLater(a, b *time.Time) *time.Time {
|
||||
if a == nil {
|
||||
return b
|
||||
}
|
||||
if b == nil {
|
||||
return a
|
||||
}
|
||||
if a.After(*b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// pickEarlier returns whichever of the two times is earlier (nil
|
||||
// propagates the non-nil value; both nil → nil).
|
||||
func pickEarlier(a, b *time.Time) *time.Time {
|
||||
if a == nil {
|
||||
return b
|
||||
}
|
||||
if b == nil {
|
||||
return a
|
||||
}
|
||||
if a.Before(*b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// inDateWindow returns true when due is inside [from, to]. Both ends are
|
||||
// optional. The deadline ListFilter has no date-range support today, so we
|
||||
// post-filter in memory — fine because the per-user deadline set is small.
|
||||
|
||||
Reference in New Issue
Block a user