feat(t-paliad-115): events view ⊥ filter — view selector + unified calendar view
PR-1 of t-paliad-115. Separates the view axis (cards / list / calendar)
from the filter axis (event type, status, project) on EventsPage. Closes
the duplicate "Kalenderansicht" button that t-paliad-110 left behind on
the Beides view.
Bug source: frontend/src/events.tsx:53-68 rendered TWO separate
"Kalenderansicht" anchors (events-action-deadline-cal +
events-action-appointment-cal) and frontend/src/client/events.ts:533-534
unhid both when type=all. Reproduced live on paliad.de — screenshot at
.playwright-mcp/paliad-115-duplicate-kalenderansicht-before.png.
Architectural change.
- Removed the per-type calendar buttons from the page header.
- Added a 3-button segmented view selector (Karten / Liste / Kalender)
next to the type chips. The two controls now sit on a shared
events-axis-row that flexes side-by-side and stacks on narrow viewports.
- View state lives in `currentView`, defaults to "cards", reads from
?view= on init, persists to URL on change. Default ("cards") stays
out of the URL so existing bookmarks don't change.
- Cards view = original (5-card summary + table). List view = table
only (cards hidden). Calendar view = month grid; cards + table both
hidden.
Calendar view.
- Plots both deadlines (due_date) and appointments (start_at local
date) on a Mo–So month grid. Reuses the existing .frist-cal-* CSS
scaffold from /deadlines/calendar; only new addition is
.events-cal-dot-appointment for the appointment hue (--bucket-next-week).
- Inherits the page's filters — `?view=calendar&type=appointment` shows
appointment-only; `?view=calendar&status=all` shows everything; etc.
Status, type, project, event-type filters apply orthogonally to view,
matching the spec's "two axes combine" requirement.
- Click a day with items → existing modal pattern lists them with type
chip + project ref; clicking an item navigates to its detail page.
- Month nav (prev / next / today) is purely client-side — no refetch,
cheap pagination over the already-loaded items.
Out of scope (per task brief).
- Standalone /deadlines/calendar + /appointments/calendar pages stay
untouched. PR-2 (URL canonicalisation) handles that surface.
- Custom time-window controls — future iteration per t-110 spec.
Build clean: `cd frontend && bun run build` + `go build/vet/test ./...`.
This commit is contained in:
@@ -24,6 +24,7 @@ declare global {
|
||||
}
|
||||
|
||||
type EventTypeChoice = "deadline" | "appointment" | "all";
|
||||
type EventView = "cards" | "list" | "calendar";
|
||||
|
||||
interface EventListItem {
|
||||
type: "deadline" | "appointment";
|
||||
@@ -88,6 +89,7 @@ interface Me {
|
||||
const PERSONAL = "__personal__";
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
let currentView: EventView = "cards";
|
||||
let statusFilter = "pending";
|
||||
let projectFilter = "";
|
||||
let appointmentTypeFilter = "";
|
||||
@@ -97,6 +99,8 @@ let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
let calYear = 0;
|
||||
let calMonth = 0;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
@@ -306,7 +310,6 @@ function applyOverdueState(overdue: number) {
|
||||
|
||||
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);
|
||||
@@ -329,13 +332,13 @@ async function loadList() {
|
||||
const resp = await fetch(`/api/events?${params.toString()}`);
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
hideTableAndCalendar();
|
||||
document.getElementById("events-empty")!.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
hideTableAndCalendar();
|
||||
return;
|
||||
}
|
||||
let data: EventListItem[] = await resp.json();
|
||||
@@ -350,16 +353,31 @@ async function loadList() {
|
||||
render();
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
hideTableAndCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
function hideTableAndCalendar() {
|
||||
const tableWrap = document.getElementById("events-table-wrap");
|
||||
const calWrap = document.getElementById("events-calendar-wrap");
|
||||
if (tableWrap) tableWrap.style.display = "none";
|
||||
if (calWrap) calWrap.hidden = true;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
if (currentView === "calendar") {
|
||||
renderCalendar();
|
||||
} else {
|
||||
renderTable();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
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 tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||||
const table = document.getElementById("events-table");
|
||||
|
||||
if (allItems.length === 0) {
|
||||
@@ -455,6 +473,163 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
|
||||
// plotting an event onto the calendar. Deadlines bucket on due_date;
|
||||
// appointments on start_at's local-date component.
|
||||
function itemDateISO(item: EventListItem): string {
|
||||
if (item.type === "deadline") {
|
||||
const src = item.due_date ?? item.event_date;
|
||||
return src.slice(0, 10);
|
||||
}
|
||||
if (!item.start_at) return item.event_date.slice(0, 10);
|
||||
const d = new Date(item.start_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtMonthYear(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function calDotClass(item: EventListItem): string {
|
||||
// Per-item dot colour. Deadlines reuse the existing urgency palette;
|
||||
// appointments get their own colour so they're visually distinct from
|
||||
// deadlines on a mixed (Beides) calendar.
|
||||
if (item.type === "appointment") return "events-cal-dot-appointment";
|
||||
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const wrap = document.getElementById("events-calendar-wrap")!;
|
||||
const grid = document.getElementById("events-cal-grid")!;
|
||||
const empty = document.getElementById("events-cal-empty") as HTMLElement;
|
||||
const monthLabel = document.getElementById("events-cal-month-label")!;
|
||||
const tableEmpty = document.getElementById("events-empty")!;
|
||||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||||
|
||||
// Calendar always renders the visible month from allItems, regardless of
|
||||
// pristine vs filtered state — empty calendar is allowed (the per-month
|
||||
// empty hint communicates "no items in this month" without confusing it
|
||||
// with the table-mode "no items at all" empty state).
|
||||
tableEmpty.style.display = "none";
|
||||
tableEmptyFiltered.style.display = "none";
|
||||
wrap.hidden = false;
|
||||
|
||||
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
|
||||
const byDate = new Map<string, EventListItem[]>();
|
||||
for (const item of allItems) {
|
||||
const iso = itemDateISO(item);
|
||||
const list = byDate.get(iso);
|
||||
if (list) list.push(item);
|
||||
else byDate.set(iso, [item]);
|
||||
}
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(calYear, calMonth, day);
|
||||
const items = byDate.get(iso) ?? [];
|
||||
const isToday = iso === todayISO;
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(calYear, calMonth, 1);
|
||||
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
|
||||
const hasInMonth = allItems.some((it) => {
|
||||
const iso = itemDateISO(it);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
empty.hidden = hasInMonth;
|
||||
}
|
||||
|
||||
function openCalPopup(iso: string, items: EventListItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
const dateEl = document.getElementById("events-cal-popup-date")!;
|
||||
const list = document.getElementById("events-cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((it) => {
|
||||
const cls = calDotClass(it);
|
||||
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
|
||||
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
|
||||
const projectLabel = it.project_reference ?? "";
|
||||
const projectCell = projectHref
|
||||
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
|
||||
: "";
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
|
||||
${projectCell}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||||
const calWrap = document.getElementById("events-calendar-wrap") as HTMLElement;
|
||||
const summary = document.getElementById("events-summary") as HTMLElement;
|
||||
|
||||
// Active state on selector buttons.
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((b) => {
|
||||
b.classList.toggle("events-view-btn-active", b.dataset.eventView === currentView);
|
||||
});
|
||||
|
||||
// Body class so CSS can hook in if needed downstream.
|
||||
document.body.classList.toggle("events-view-cards", currentView === "cards");
|
||||
document.body.classList.toggle("events-view-list", currentView === "list");
|
||||
document.body.classList.toggle("events-view-calendar", currentView === "calendar");
|
||||
|
||||
// Cards view = the original layout (5-card summary + table).
|
||||
// List view = no summary cards, table only — gives more vertical space
|
||||
// and matches users' mental model of a flat list.
|
||||
// Calendar view = month grid; cards + table both hidden.
|
||||
summary.style.display = currentView === "cards" ? "" : "none";
|
||||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||||
calWrap.hidden = currentView !== "calendar";
|
||||
|
||||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
@@ -529,9 +704,7 @@ function applyTypeVisibility() {
|
||||
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");
|
||||
// Single "Neu …" CTA per type. View axis is the calendar/list/cards selector.
|
||||
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
|
||||
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
|
||||
|
||||
@@ -594,6 +767,7 @@ function toggleFilterPair(controlID: string, show: boolean, labelID?: string) {
|
||||
function syncURLParams() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("type");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("status");
|
||||
url.searchParams.delete("project_id");
|
||||
url.searchParams.delete("event_type");
|
||||
@@ -601,6 +775,7 @@ function syncURLParams() {
|
||||
// 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 (currentView !== "cards") url.searchParams.set("view", currentView);
|
||||
if (currentType !== "appointment" && statusFilter && statusFilter !== "pending") {
|
||||
url.searchParams.set("status", statusFilter);
|
||||
}
|
||||
@@ -633,6 +808,10 @@ function populateProjectFilter() {
|
||||
function initFilters() {
|
||||
const params = urlParams();
|
||||
currentType = (params.get("type") as EventTypeChoice) || defaultType();
|
||||
const rawView = params.get("view");
|
||||
if (rawView === "cards" || rawView === "list" || rawView === "calendar") {
|
||||
currentView = rawView;
|
||||
}
|
||||
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")!;
|
||||
@@ -697,6 +876,49 @@ function initFilters() {
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
// Calendar always opens on the current month — month navigation is
|
||||
// local to the view (cheap pagination, doesn't refetch).
|
||||
const now = new Date();
|
||||
calYear = now.getFullYear();
|
||||
calMonth = now.getMonth();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.dataset.eventView as EventView;
|
||||
if (next === currentView) return;
|
||||
currentView = next;
|
||||
applyView();
|
||||
syncURLParams();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
|
||||
calMonth -= 1;
|
||||
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-next")?.addEventListener("click", () => {
|
||||
calMonth += 1;
|
||||
if (calMonth > 11) { calMonth = 0; calYear += 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-today")?.addEventListener("click", () => {
|
||||
const t = new Date();
|
||||
calYear = t.getFullYear();
|
||||
calMonth = t.getMonth();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
|
||||
popup.style.display = "none";
|
||||
});
|
||||
popup?.addEventListener("click", (e) => {
|
||||
if (e.target === popup) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
||||
card.addEventListener("click", async () => {
|
||||
@@ -717,8 +939,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initView();
|
||||
initSummaryCards();
|
||||
applyTypeVisibility();
|
||||
applyView();
|
||||
onLangChange(() => {
|
||||
applyTypeVisibility();
|
||||
render();
|
||||
|
||||
@@ -1126,6 +1126,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.",
|
||||
"events.view.cards": "Karten",
|
||||
"events.view.list": "Liste",
|
||||
"events.view.calendar": "Kalender",
|
||||
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
|
||||
"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.",
|
||||
@@ -2619,6 +2623,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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.",
|
||||
"events.view.cards": "Cards",
|
||||
"events.view.list": "List",
|
||||
"events.view.calendar": "Calendar",
|
||||
"events.calendar.empty": "No entries in the selected period.",
|
||||
"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.",
|
||||
|
||||
@@ -50,22 +50,6 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
|
||||
</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"
|
||||
@@ -86,34 +70,76 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
|
||||
</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"
|
||||
<div className="events-axis-row">
|
||||
<div
|
||||
className="agenda-chip-row event-type-chip-row"
|
||||
role="tablist"
|
||||
id="events-type-chips"
|
||||
aria-label="Typ"
|
||||
>
|
||||
Fristen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agenda-chip"
|
||||
data-event-type="appointment"
|
||||
data-i18n="events.toggle.appointment"
|
||||
role="tab"
|
||||
<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="events-view-selector"
|
||||
role="tablist"
|
||||
id="events-view-selector"
|
||||
aria-label="Ansicht"
|
||||
>
|
||||
Termine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agenda-chip"
|
||||
data-event-type="all"
|
||||
data-i18n="events.toggle.all"
|
||||
role="tab"
|
||||
>
|
||||
Beides
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="events-view-btn"
|
||||
data-event-view="cards"
|
||||
data-i18n="events.view.cards"
|
||||
role="tab"
|
||||
>
|
||||
Karten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="events-view-btn"
|
||||
data-event-view="list"
|
||||
data-i18n="events.view.list"
|
||||
role="tab"
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="events-view-btn"
|
||||
data-event-view="calendar"
|
||||
data-i18n="events.view.calendar"
|
||||
role="tab"
|
||||
>
|
||||
Kalender
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="frist-summary-cards" id="events-summary">
|
||||
@@ -185,7 +211,7 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<div className="entity-table-wrap" id="events-table-wrap">
|
||||
<table className="entity-table fristen-table events-table" id="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -205,6 +231,38 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
<div className="frist-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="events-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
|
||||
Keine Einträge im ausgewählten Zeitraum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-overlay" id="events-cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="events-cal-popup-date" />
|
||||
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -829,6 +829,7 @@ export type I18nKey =
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "events.calendar.empty"
|
||||
| "events.col.appointment_type"
|
||||
| "events.col.date"
|
||||
| "events.col.location"
|
||||
@@ -842,6 +843,9 @@ export type I18nKey =
|
||||
| "events.toggle.appointment"
|
||||
| "events.toggle.deadline"
|
||||
| "events.unavailable"
|
||||
| "events.view.calendar"
|
||||
| "events.view.cards"
|
||||
| "events.view.list"
|
||||
| "footer.text"
|
||||
| "gebuehren.col.courtfee"
|
||||
| "gebuehren.col.fee"
|
||||
|
||||
@@ -8702,9 +8702,67 @@ dialog.quick-add-sheet::backdrop {
|
||||
cards — reuses the agenda-chip visuals so users get muscle-memory
|
||||
parity with /agenda. */
|
||||
.event-type-chip-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* View ⊥ filter (t-paliad-115). The type-chip row is the FILTER axis;
|
||||
the view selector is the VIEW axis (cards / list / calendar). The two
|
||||
live side-by-side in `events-axis-row` so users can scan them as
|
||||
independent controls. On narrow viewports they stack and the view
|
||||
selector wraps below the chips. */
|
||||
.events-axis-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.events-view-selector {
|
||||
display: inline-flex;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.events-view-btn {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.events-view-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.events-view-btn-active {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* Calendar view (t-paliad-115). Reuses the existing .frist-calendar
|
||||
styles — only the appointment dot colour is new. The frist-cal-dot
|
||||
urgency variants already cover the deadline palette; we just need a
|
||||
distinct hue for appointments so a mixed-type cell reads at a glance. */
|
||||
.events-calendar-wrap {
|
||||
margin: 0.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.frist-cal-dot.events-cal-dot-appointment {
|
||||
background: var(--bucket-next-week, #1d4ed8);
|
||||
}
|
||||
|
||||
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
|
||||
.event-type-add-modal {
|
||||
width: 28rem;
|
||||
|
||||
Reference in New Issue
Block a user