Initialize GovOPlaN calendar module

This commit is contained in:
2026-07-07 02:49:44 +02:00
commit 3bcb04ff76
36 changed files with 3410 additions and 0 deletions

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/calendar.css": "./src/styles/calendar.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

92
webui/src/api/calendar.ts Normal file
View File

@@ -0,0 +1,92 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type CalendarCollection = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
timezone: string;
color?: string | null;
owner_type: string;
owner_id?: string | null;
visibility: string;
is_default: boolean;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
};
export type CalendarEvent = {
id: string;
tenant_id: string;
calendar_id: string;
uid: string;
recurrence_id?: string | null;
sequence: number;
summary: string;
description?: string | null;
location?: string | null;
status: string;
transparency: string;
classification: string;
start_at: string;
end_at?: string | null;
duration_seconds?: number | null;
all_day: boolean;
timezone?: string | null;
organizer?: Record<string, unknown> | null;
attendees: Record<string, unknown>[];
categories: string[];
rrule?: Record<string, unknown> | null;
rdate: Record<string, unknown>[];
exdate: Record<string, unknown>[];
reminders: Record<string, unknown>[];
attachments: Record<string, unknown>[];
related_to: Record<string, unknown>[];
source_kind: string;
source_href?: string | null;
etag?: string | null;
icalendar: Record<string, unknown>;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
};
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
export type CalendarEventListResponse = { events: CalendarEvent[] };
export type CalendarEventCreatePayload = {
calendar_id?: string | null;
summary: string;
description?: string | null;
location?: string | null;
start_at: string;
end_at?: string | null;
all_day?: boolean;
timezone?: string | null;
status?: string;
transparency?: string;
classification?: string;
categories?: string[];
};
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
}
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
): Promise<CalendarEventListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.start_at) search.set("start_at", params.start_at);
if (params.end_at) search.set("end_at", params.end_at);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -0,0 +1,511 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { CalendarDays, ChevronLeft, ChevronRight, Plus, RefreshCw, X } from "lucide-react";
import { DismissibleAlert, LoadingIndicator, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import { createCalendarEvent, listCalendarEvents, listCalendars, type CalendarCollection, type CalendarEvent } from "../../api/calendar";
type CalendarMode = "month" | "week" | "workweek" | "day" | "continuous";
type Range = { start: Date; end: Date };
const modeOptions: { id: CalendarMode; label: string }[] = [
{ id: "month", label: "Month" },
{ id: "week", label: "Week" },
{ id: "workweek", label: "Workweek" },
{ id: "day", label: "Day" },
{ id: "continuous", label: "Continuous" }
];
export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [selectedCalendarId, setSelectedCalendarId] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [mode, setMode] = useState<CalendarMode>("month");
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const selectedCalendar = calendars.find((calendar) => calendar.id === selectedCalendarId) ?? calendars[0] ?? null;
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const eventsByDay = useMemo(() => groupEventsByDay(events), [events]);
const canWrite = hasScope(auth, "calendar:event:write");
const heading = headingForMode(mode, focusDate, visibleRange);
useEffect(() => {
void loadCalendars();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (calendars.length) void loadEvents();
}, [calendars.length, selectedCalendarId, visibleRange.start.getTime(), visibleRange.end.getTime()]);
async function loadCalendars() {
setLoading(true);
setError("");
try {
const response = await listCalendars(settings);
setCalendars(response.calendars);
setSelectedCalendarId((current) => current && response.calendars.some((calendar) => calendar.id === current) ? current : response.calendars[0]?.id ?? "");
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}
async function loadEvents() {
setLoading(true);
setError("");
try {
const response = await listCalendarEvents(settings, {
calendar_id: selectedCalendarId || undefined,
start_at: visibleRange.start.toISOString(),
end_at: visibleRange.end.toISOString()
});
setEvents(response.events);
} catch (err) {
setError(errorText(err));
setEvents([]);
} finally {
setLoading(false);
}
}
function moveFocus(direction: -1 | 1) {
const daysToMove = mode === "month" ? 31 : mode === "day" ? 1 : 7;
setFocusDate((current) => mode === "month" ? addMonths(current, direction) : addDays(current, direction * daysToMove));
}
function handleContinuousScroll() {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
if (node.scrollTop < 160) {
const oldHeight = node.scrollHeight;
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
window.requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop += scrollRef.current.scrollHeight - oldHeight;
});
}
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) {
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
}
}
async function handleCreate(payload: QuickCreatePayload) {
setSaving(true);
setError("");
try {
await createCalendarEvent(settings, payload);
setCreateOpen(false);
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
return (
<main className="calendar-page">
<header className="calendar-toolbar">
<div className="calendar-title-block">
<div className="calendar-title-row">
<CalendarDays size={22} aria-hidden="true" />
<h1>Calendar</h1>
</div>
<span>{heading}</span>
</div>
<div className="calendar-toolbar-actions">
<div className="calendar-icon-group" aria-label="Calendar navigation">
<button type="button" className="calendar-icon-button" title="Previous" aria-label="Previous" onClick={() => moveFocus(-1)}>
<ChevronLeft size={18} />
</button>
<button type="button" className="calendar-text-button" onClick={() => setFocusDate(startOfDay(new Date()))}>Today</button>
<button type="button" className="calendar-icon-button" title="Next" aria-label="Next" onClick={() => moveFocus(1)}>
<ChevronRight size={18} />
</button>
</div>
<div className="calendar-mode-switch" role="tablist" aria-label="Calendar views">
{modeOptions.map((option) => (
<button
key={option.id}
type="button"
role="tab"
aria-selected={mode === option.id}
className={mode === option.id ? "is-active" : ""}
onClick={() => setMode(option.id)}
>
{option.label}
</button>
))}
</div>
<select className="calendar-select" value={selectedCalendarId} onChange={(event) => setSelectedCalendarId(event.target.value)} aria-label="Calendar">
{calendars.map((calendar) => <option key={calendar.id} value={calendar.id}>{calendar.name}</option>)}
</select>
<button type="button" className="calendar-icon-button" title="Refresh" aria-label="Refresh" onClick={() => void loadEvents()}>
<RefreshCw size={18} />
</button>
{canWrite && (
<button type="button" className="calendar-primary-button" onClick={() => setCreateOpen(true)}>
<Plus size={17} /> New
</button>
)}
</div>
</header>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<section className="calendar-workspace">
<aside className="calendar-sidebar">
<h2>Calendars</h2>
<div className="calendar-list">
{calendars.map((calendar) => (
<button
key={calendar.id}
type="button"
className={calendar.id === selectedCalendarId ? "is-selected" : ""}
onClick={() => setSelectedCalendarId(calendar.id)}
>
<span style={{ backgroundColor: calendar.color || "#0f766e" }} />
{calendar.name}
</button>
))}
</div>
<h2>Agenda</h2>
<div className="calendar-agenda">
{events.slice(0, 12).map((event) => (
<article key={event.id}>
<strong>{event.summary}</strong>
<span>{eventTimeLabel(event)}</span>
</article>
))}
{!events.length && !loading && <p>No events</p>}
</div>
</aside>
<div className="calendar-view-shell">
{loading && <div className="calendar-loading"><LoadingIndicator label="Loading calendar..." /></div>}
{mode === "continuous" ? (
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll}>
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} continuous />
</div>
) : mode === "month" ? (
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} />
) : (
<CalendarTimeGrid days={days} eventsByDay={eventsByDay} />
)}
</div>
</section>
{createOpen && selectedCalendar && (
<QuickCreateDialog
calendar={selectedCalendar}
focusDate={focusDate}
saving={saving}
onCancel={() => setCreateOpen(false)}
onCreate={handleCreate}
/>
)}
</main>
);
}
function CalendarWeekRows({ days, eventsByDay, focusDate, continuous = false }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]>; focusDate: Date; continuous?: boolean }) {
const weeks = chunk(days, 7);
return (
<div className={continuous ? "calendar-week-rows is-continuous" : "calendar-week-rows"}>
<div className="calendar-weekday-header">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => <span key={label}>{label}</span>)}
</div>
{weeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
return (
<section key={key} className={`calendar-day-cell ${sameMonth(day, focusDate) ? "" : "is-muted"} ${sameDay(day, new Date()) ? "is-today" : ""}`}>
<header>
<span>{day.getDate()}</span>
{continuous && day.getDate() <= 7 && <small>{monthLabel(day)}</small>}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, 4).map((event) => <CalendarEventChip key={event.id} event={event} />)}
{dayEvents.length > 4 && <span className="calendar-more-events">+{dayEvents.length - 4}</span>}
</div>
</section>
);
})}
</div>
))}
</div>
);
}
function CalendarTimeGrid({ days, eventsByDay }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
const hours = Array.from({ length: 12 }, (_item, index) => index + 7);
return (
<div className="calendar-time-grid" style={{ gridTemplateColumns: `72px repeat(${days.length}, minmax(132px, 1fr))` }}>
<div className="calendar-time-corner" />
{days.map((day) => (
<header key={dayKey(day)} className={sameDay(day, new Date()) ? "is-today" : ""}>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
{hours.map((hour) => (
<TimeRow key={hour} hour={hour} days={days} eventsByDay={eventsByDay} />
))}
</div>
);
}
function TimeRow({ hour, days, eventsByDay }: { hour: number; days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
return (
<>
<div className="calendar-hour-label">{String(hour).padStart(2, "0")}:00</div>
{days.map((day) => {
const events = (eventsByDay.get(dayKey(day)) ?? []).filter((event) => event.all_day || new Date(event.start_at).getHours() === hour);
return (
<div key={`${dayKey(day)}-${hour}`} className="calendar-hour-cell">
{events.map((event) => <CalendarEventChip key={event.id} event={event} compact />)}
</div>
);
})}
</>
);
}
function CalendarEventChip({ event, compact = false }: { event: CalendarEvent; compact?: boolean }) {
return (
<article className={compact ? "calendar-event-chip is-compact" : "calendar-event-chip"}>
<strong>{event.summary}</strong>
{!compact && <span>{eventTimeLabel(event)}</span>}
</article>
);
}
type QuickCreatePayload = {
calendar_id: string;
summary: string;
location?: string | null;
start_at: string;
end_at?: string | null;
all_day: boolean;
};
function QuickCreateDialog({
calendar,
focusDate,
saving,
onCancel,
onCreate
}: {
calendar: CalendarCollection;
focusDate: Date;
saving: boolean;
onCancel: () => void;
onCreate: (payload: QuickCreatePayload) => Promise<void>;
}) {
const [summary, setSummary] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState(toInputDate(focusDate));
const [start, setStart] = useState("09:00");
const [end, setEnd] = useState("10:00");
const [allDay, setAllDay] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const startAt = allDay ? localDateTime(date, "00:00") : localDateTime(date, start);
const endAt = allDay ? addDays(startAt, 1) : localDateTime(date, end);
await onCreate({
calendar_id: calendar.id,
summary,
location: location || null,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
all_day: allDay
});
}
return (
<div className="calendar-dialog-backdrop" role="presentation">
<form className="calendar-dialog" onSubmit={submit}>
<header>
<h2>New event</h2>
<button type="button" className="calendar-icon-button" title="Close" aria-label="Close" onClick={onCancel}>
<X size={18} />
</button>
</header>
<label>
<span>Title</span>
<input value={summary} onChange={(event) => setSummary(event.target.value)} required maxLength={500} autoFocus />
</label>
<label>
<span>Location</span>
<input value={location} onChange={(event) => setLocation(event.target.value)} maxLength={500} />
</label>
<label>
<span>Date</span>
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} required />
</label>
<div className="calendar-dialog-row">
<label>
<span>Start</span>
<input type="time" value={start} onChange={(event) => setStart(event.target.value)} disabled={allDay} />
</label>
<label>
<span>End</span>
<input type="time" value={end} onChange={(event) => setEnd(event.target.value)} disabled={allDay} />
</label>
</div>
<label className="calendar-checkbox">
<input type="checkbox" checked={allDay} onChange={(event) => setAllDay(event.target.checked)} />
<span>All day</span>
</label>
<footer>
<button type="button" className="calendar-text-button" onClick={onCancel}>Cancel</button>
<button type="submit" className="calendar-primary-button" disabled={saving}>{saving ? "Saving..." : "Create"}</button>
</footer>
</form>
</div>
);
}
function rangeForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
const end = addDays(startOfWeek(addMonths(startOfMonth(focusDate), 1)), 7);
return { start, end };
}
if (mode === "day") return { start: startOfDay(focusDate), end: addDays(startOfDay(focusDate), 1) };
if (mode === "continuous") {
const start = addDays(startOfWeek(focusDate), -continuousWeeks.before * 7);
const end = addDays(startOfWeek(focusDate), continuousWeeks.after * 7);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
function daysForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
function groupEventsByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
const last = startOfDay(end);
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(key, items.sort((left, right) => left.start_at.localeCompare(right.start_at)));
}
return grouped;
}
function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "All day";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
function headingForMode(mode: CalendarMode, focusDate: Date, range: Range): string {
if (mode === "day") return dayMonthLabel(focusDate);
if (mode === "month") return `${monthLabel(focusDate)} ${focusDate.getFullYear()}`;
if (mode === "continuous") return `${dayMonthLabel(range.start)} - ${dayMonthLabel(addDays(range.end, -1))}`;
return `${dayMonthLabel(range.start)} - ${dayMonthLabel(addDays(range.end, -1))}`;
}
function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) rows.push(items.slice(index, index + size));
return rows;
}
function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (let cursor = startOfDay(start); cursor < end; cursor = addDays(cursor, 1)) days.push(cursor);
return days;
}
function dayKey(value: Date): string {
return toInputDate(value);
}
function sameDay(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
}
function sameMonth(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
}
function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
}
function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
function monthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { month: "long" }).format(value);
}
function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short" }).format(value);
}
function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(value);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(value);
}
function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "Calendar request failed.";
}

2
webui/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { calendarModule as default, calendarModule } from "./module";
export * from "./api/calendar";

21
webui/src/module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/calendar.css";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const eventRead = ["calendar:event:read"];
export const calendarModule: PlatformWebModule = {
id: "calendar",
label: "Calendar",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
navItems: [{ to: "/calendar", label: "Calendar", iconName: "calendar", anyOf: eventRead, order: 55 }],
routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }
]
};
export default calendarModule;

View File

@@ -0,0 +1,445 @@
.calendar-page {
min-height: 100%;
padding: 24px;
color: #172026;
background: #f5f7f8;
}
.calendar-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.calendar-title-block {
display: grid;
gap: 4px;
min-width: 220px;
}
.calendar-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.calendar-title-row h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.calendar-title-block span {
color: #5b6770;
font-size: 14px;
}
.calendar-toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.calendar-icon-group,
.calendar-mode-switch {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border: 1px solid #d6dde1;
background: #ffffff;
}
.calendar-mode-switch button,
.calendar-text-button,
.calendar-primary-button,
.calendar-icon-button,
.calendar-select {
border: 1px solid #cfd8dc;
background: #ffffff;
color: #172026;
font: inherit;
}
.calendar-mode-switch button {
min-height: 32px;
padding: 0 10px;
border: 0;
}
.calendar-mode-switch button.is-active {
background: #0f766e;
color: #ffffff;
}
.calendar-icon-button {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
}
.calendar-text-button,
.calendar-primary-button {
min-height: 34px;
padding: 0 12px;
}
.calendar-primary-button {
display: inline-flex;
align-items: center;
gap: 7px;
border-color: #0f766e;
background: #0f766e;
color: #ffffff;
font-weight: 650;
}
.calendar-primary-button:disabled {
opacity: 0.65;
}
.calendar-select {
min-height: 36px;
max-width: 240px;
padding: 0 10px;
}
.calendar-workspace {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
gap: 18px;
min-height: calc(100vh - 150px);
}
.calendar-sidebar {
display: flex;
flex-direction: column;
gap: 12px;
border-right: 1px solid #dbe2e6;
padding-right: 18px;
}
.calendar-sidebar h2 {
margin: 4px 0 0;
font-size: 13px;
color: #4d5a61;
text-transform: uppercase;
}
.calendar-list,
.calendar-agenda {
display: grid;
gap: 6px;
}
.calendar-list button {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
border: 1px solid transparent;
background: transparent;
text-align: left;
font: inherit;
}
.calendar-list button.is-selected {
border-color: #b8c8cc;
background: #ffffff;
}
.calendar-list button span {
width: 10px;
height: 10px;
border-radius: 50%;
}
.calendar-agenda article {
display: grid;
gap: 2px;
padding: 8px 0;
border-bottom: 1px solid #e1e6e8;
}
.calendar-agenda strong {
font-size: 13px;
}
.calendar-agenda span,
.calendar-agenda p {
margin: 0;
color: #5f6f78;
font-size: 12px;
}
.calendar-view-shell {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid #d7e0e4;
background: #ffffff;
}
.calendar-loading {
position: absolute;
inset: 0 auto auto 0;
z-index: 2;
padding: 10px;
background: rgba(255, 255, 255, 0.86);
}
.calendar-week-rows {
min-width: 760px;
}
.calendar-weekday-header,
.calendar-week-row {
display: grid;
grid-template-columns: repeat(7, minmax(104px, 1fr));
}
.calendar-weekday-header {
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid #d7e0e4;
background: #edf3f3;
}
.calendar-weekday-header span {
min-height: 34px;
padding: 9px 10px;
color: #506169;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.calendar-week-row {
min-height: 132px;
border-bottom: 1px solid #d7e0e4;
}
.calendar-day-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 132px;
padding: 8px;
border-right: 1px solid #e1e7ea;
background: #ffffff;
}
.calendar-day-cell.is-muted {
background: #f7f9fa;
color: #738089;
}
.calendar-day-cell.is-today {
box-shadow: inset 0 0 0 2px #0f766e;
}
.calendar-day-cell header {
display: flex;
align-items: baseline;
justify-content: space-between;
min-height: 20px;
}
.calendar-day-cell header span {
font-weight: 700;
}
.calendar-day-cell header small {
color: #63747d;
}
.calendar-event-stack {
display: grid;
gap: 4px;
}
.calendar-event-chip {
display: grid;
gap: 2px;
min-height: 34px;
padding: 5px 7px;
border-left: 3px solid #0f766e;
background: #e7f4f1;
color: #16332f;
font-size: 12px;
}
.calendar-event-chip.is-compact {
min-height: 28px;
margin: 3px 4px;
background: #e7f4f1;
}
.calendar-event-chip strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-event-chip span {
color: #4d6764;
}
.calendar-more-events {
color: #596970;
font-size: 12px;
}
.calendar-continuous {
max-height: calc(100vh - 172px);
overflow: auto;
}
.calendar-week-rows.is-continuous .calendar-week-row {
min-height: 82px;
}
.calendar-week-rows.is-continuous .calendar-day-cell {
min-height: 82px;
}
.calendar-time-grid {
display: grid;
min-width: 720px;
}
.calendar-time-grid > header,
.calendar-time-corner {
position: sticky;
top: 0;
z-index: 1;
display: grid;
align-content: center;
min-height: 54px;
padding: 8px 10px;
border-bottom: 1px solid #d7e0e4;
border-right: 1px solid #d7e0e4;
background: #edf3f3;
}
.calendar-time-grid > header.is-today {
background: #dbeeea;
}
.calendar-time-grid > header span {
color: #52646d;
font-size: 12px;
}
.calendar-hour-label,
.calendar-hour-cell {
min-height: 64px;
border-bottom: 1px solid #e3e9ec;
border-right: 1px solid #e3e9ec;
}
.calendar-hour-label {
padding: 8px;
color: #62737c;
font-size: 12px;
background: #f7f9fa;
}
.calendar-hour-cell {
background: #ffffff;
}
.calendar-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 32, 0.38);
}
.calendar-dialog {
display: grid;
gap: 14px;
width: min(460px, 100%);
padding: 18px;
border: 1px solid #cbd5d9;
background: #ffffff;
box-shadow: 0 18px 50px rgba(21, 33, 42, 0.22);
}
.calendar-dialog header,
.calendar-dialog footer,
.calendar-dialog-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.calendar-dialog h2 {
margin: 0;
font-size: 18px;
}
.calendar-dialog label {
display: grid;
gap: 5px;
font-size: 13px;
color: #4b5b63;
}
.calendar-dialog input {
min-height: 36px;
border: 1px solid #c9d3d8;
padding: 0 9px;
color: #172026;
font: inherit;
}
.calendar-dialog-row label {
flex: 1;
}
.calendar-checkbox {
display: flex !important;
grid-template-columns: none !important;
align-items: center;
gap: 8px !important;
}
.calendar-checkbox input {
min-height: auto;
}
@media (max-width: 900px) {
.calendar-page {
padding: 16px;
}
.calendar-toolbar {
display: grid;
}
.calendar-toolbar-actions {
justify-content: flex-start;
}
.calendar-workspace {
grid-template-columns: 1fr;
}
.calendar-sidebar {
border-right: 0;
border-bottom: 1px solid #dbe2e6;
padding: 0 0 14px;
}
}