rspace-online/modules/rschedule/components/folk-schedule-admin.ts

627 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-schedule-admin> — admin dashboard for rSchedule (passkey-gated).
*
* Tabs: Overview · Availability · Bookings · Google Calendar · Settings.
* All mutations go through `/api/admin/*` which enforces moderator role
* server-side via resolveCallerRole.
*/
interface Settings {
displayName: string;
email: string;
bookingMessage: string;
slotDurationMin: number;
bufferBeforeMin: number;
bufferAfterMin: number;
minNoticeHours: number;
maxAdvanceDays: number;
timezone: string;
autoShiftTimezone: boolean;
}
interface Rule { id: string; dayOfWeek: number; startTime: string; endTime: string; isActive: boolean; }
interface Override { id: string; date: string; isBlocked: boolean; startTime: string | null; endTime: string | null; reason: string; }
interface BookingRow {
id: string; guestName: string; guestEmail: string;
startTime: number; endTime: number; timezone: string;
status: string; attendeeCount: number;
}
interface GcalStatus {
connected: boolean; email: string | null; calendarIds: string[];
syncToken: string | null; lastSyncAt: number | null;
lastSyncStatus: "ok" | "error" | null; lastSyncError: string | null;
}
interface InvitationRow {
id: string;
bookingId: string;
host: { kind: string; id: string; label?: string };
title: string;
startTime: number;
endTime: number;
timezone: string;
status: string;
response: "invited" | "accepted" | "declined";
meetingLink: string | null;
}
type Tab = "overview" | "availability" | "bookings" | "invitations" | "calendar" | "settings";
class FolkScheduleAdmin extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private tab: Tab = "overview";
private err = "";
private info = "";
private authOk = false;
private settings: Settings | null = null;
private rules: Rule[] = [];
private overrides: Override[] = [];
private bookings: BookingRow[] = [];
private invitations: InvitationRow[] = [];
private gcal: GcalStatus | null = null;
private busy = false;
private browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
void this.init();
}
private apiBase(): string {
const path = window.location.pathname;
const m = path.match(/^(\/[^/]+)?\/rschedule/);
return `${m?.[0] || "/rschedule"}/api/admin`;
}
private authHeaders(): Record<string, string> {
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
const token = sess?.token;
return token ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" };
} catch { return { "Content-Type": "application/json" }; }
}
private async api<T = any>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.apiBase()}${path}`, {
...init,
headers: { ...this.authHeaders(), ...(init.headers || {}) },
});
if (res.status === 401) { this.authOk = false; this.err = "Sign in with a passkey to manage this booking page."; throw new Error("unauth"); }
if (res.status === 403) { this.authOk = false; this.err = "Moderator or admin role required for this space."; throw new Error("forbidden"); }
if (!res.ok) { const e = await res.json().catch(() => ({})); this.err = e.error || `Error ${res.status}`; throw new Error(this.err); }
return res.json();
}
private async init() {
try {
const { settings } = await this.api<{ settings: Settings }>("/settings");
this.authOk = true;
this.settings = settings;
await Promise.all([this.loadRules(), this.loadOverrides(), this.loadBookings(), this.loadInvitations(), this.loadGcal()]);
} catch { /* err set in api() */ }
this.render();
}
private async loadRules() {
try { const { rules } = await this.api<{ rules: Rule[] }>("/availability"); this.rules = rules; } catch {}
}
private async loadOverrides() {
try { const { overrides } = await this.api<{ overrides: Override[] }>("/overrides"); this.overrides = overrides; } catch {}
}
private async loadBookings() {
try { const { bookings } = await this.api<{ bookings: BookingRow[] }>("/bookings"); this.bookings = bookings; } catch {}
}
private async loadGcal() {
try { this.gcal = await this.api<GcalStatus>("/google/status"); } catch {}
}
private async loadInvitations() {
try {
// Public endpoint — no /admin prefix
const base = this.apiBase().replace(/\/api\/admin$/, "/api");
const res = await fetch(`${base}/invitations`);
if (res.ok) {
const data = await res.json() as { invitations: InvitationRow[] };
this.invitations = data.invitations;
}
} catch {}
}
private async respondToInvitation(id: string, response: "accepted" | "declined") {
try {
await this.api(`/../invitations/${id}`, { method: "PATCH", body: JSON.stringify({ response }) });
} catch {
// Fallback: use the public invitations endpoint with admin auth
try {
const base = this.apiBase().replace(/\/admin$/, "");
await fetch(`${base}/invitations/${id}`, {
method: "PATCH",
headers: this.authHeaders(),
body: JSON.stringify({ response }),
});
} catch {}
}
await this.loadInvitations();
this.info = `Invitation ${response}.`;
this.render();
}
private async shiftTimezone() {
if (!this.settings) return;
const newTz = this.browserTz;
const relabel = confirm(`Change schedule timezone from ${this.settings.timezone} to ${newTz} and relabel existing bookings?`);
try {
await this.api("/timezone/shift", {
method: "POST",
body: JSON.stringify({ timezone: newTz, relabelBookings: relabel }),
});
this.info = `Timezone changed to ${newTz}.`;
await this.init();
} catch {}
}
// ── Settings ──
private async saveSettings(patch: Partial<Settings>) {
this.busy = true; this.render();
try {
const { settings } = await this.api<{ settings: Settings }>("/settings", {
method: "PUT", body: JSON.stringify(patch),
});
this.settings = settings;
this.info = "Settings saved.";
} catch {}
this.busy = false; this.render();
}
// ── Rules ──
private async addRule(r: Omit<Rule, "id">) {
this.busy = true; this.render();
try { await this.api("/availability", { method: "POST", body: JSON.stringify(r) }); await this.loadRules(); this.info = "Rule added."; } catch {}
this.busy = false; this.render();
}
private async updateRule(id: string, patch: Partial<Rule>) {
try { await this.api(`/availability/${id}`, { method: "PUT", body: JSON.stringify(patch) }); await this.loadRules(); } catch {}
this.render();
}
private async deleteRule(id: string) {
try { await this.api(`/availability/${id}`, { method: "DELETE" }); await this.loadRules(); } catch {}
this.render();
}
// ── Overrides ──
private async addOverride(o: Omit<Override, "id">) {
try { await this.api("/overrides", { method: "POST", body: JSON.stringify(o) }); await this.loadOverrides(); this.info = "Override added."; } catch {}
this.render();
}
private async deleteOverride(id: string) {
try { await this.api(`/overrides/${id}`, { method: "DELETE" }); await this.loadOverrides(); } catch {}
this.render();
}
// ── Gcal ──
private async syncGcal() {
this.busy = true; this.render();
try {
const r = await this.api<{ ok: boolean; eventsUpdated: number; deleted: number; error?: string }>("/google/sync", { method: "POST" });
this.info = r.ok ? `Synced. +${r.eventsUpdated} events, -${r.deleted} deleted.` : `Sync failed: ${r.error}`;
await this.loadGcal();
} catch {}
this.busy = false; this.render();
}
private async disconnectGcal() {
if (!confirm("Disconnect Google Calendar from this rSchedule? Tokens remain (use OAuth disconnect to fully revoke).")) return;
try { await this.api("/google/disconnect", { method: "POST" }); await this.loadGcal(); this.info = "Disconnected."; } catch {}
this.render();
}
private render() {
this.shadow.innerHTML = `
<style>${STYLES}</style>
<div class="wrap">
<header>
<h1>rSchedule admin · ${escapeHtml(this.space)}</h1>
<p class="sub">Configure your bookable availability, Google Calendar sync, and booking policies.</p>
</header>
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
${this.info ? `<div class="info">${escapeHtml(this.info)}</div>` : ""}
${this.authOk ? `
${this.renderTzBanner()}
<nav class="tabs">
${this.tabBtn("overview", "Overview")}
${this.tabBtn("availability", "Availability")}
${this.tabBtn("bookings", `Bookings${this.bookings.length ? ` (${this.bookings.length})` : ""}`)}
${this.tabBtn("invitations", `Invitations${this.invitations.length ? ` (${this.invitations.length})` : ""}`)}
${this.tabBtn("calendar", "Google Calendar")}
${this.tabBtn("settings", "Settings")}
</nav>
<section class="panel">${this.renderPanel()}</section>
` : `<div class="unauth">Sign in with a passkey to continue. Admin pages are restricted to moderators and admins of this space.</div>`}
</div>
`;
this.wire();
}
private renderPanel(): string {
switch (this.tab) {
case "overview": return this.renderOverview();
case "availability": return this.renderAvailability();
case "bookings": return this.renderBookings();
case "invitations": return this.renderInvitations();
case "calendar": return this.renderCalendar();
case "settings": return this.renderSettings();
}
}
private renderTzBanner(): string {
if (!this.settings) return "";
if (this.browserTz && this.browserTz !== this.settings.timezone) {
return `<div class="tz-banner">
Your browser is on <strong>${escapeHtml(this.browserTz)}</strong> but this schedule is configured for <strong>${escapeHtml(this.settings.timezone)}</strong>.
<button class="primary small" id="tz-shift">Shift to ${escapeHtml(this.browserTz)}</button>
</div>`;
}
return "";
}
private renderInvitations(): string {
return `
<h2>Bookings you're invited to</h2>
<p class="muted">Meetings hosted by other spaces or users where <strong>${escapeHtml(this.space)}</strong> is an attendee.</p>
${this.invitations.length === 0 ? `<p class="muted">No invitations.</p>` : `
<table class="tbl">
<thead><tr><th>When</th><th>Host</th><th>Title</th><th>Status</th><th>Response</th><th></th></tr></thead>
<tbody>
${this.invitations.map(inv => `<tr data-inv-id="${inv.id}">
<td>${escapeHtml(new Date(inv.startTime).toLocaleString())}</td>
<td>${escapeHtml(inv.host.label || inv.host.id)}</td>
<td>${escapeHtml(inv.title)}</td>
<td><span class="badge badge-${inv.status}">${inv.status}</span></td>
<td><span class="badge badge-${inv.response}">${inv.response}</span></td>
<td>
${inv.response !== "accepted" ? `<button class="secondary small inv-accept">Accept</button>` : ""}
${inv.response !== "declined" ? `<button class="danger inv-decline">Decline</button>` : ""}
</td>
</tr>`).join("")}
</tbody>
</table>
`}
`;
}
private renderOverview(): string {
const upcoming = this.bookings.filter((b) => b.status === "confirmed" && b.startTime > Date.now()).slice(0, 5);
const base = this.apiBase().replace(/\/api\/admin$/, "");
return `
<div class="grid2">
<div class="card">
<h2>Share your booking link</h2>
<p class="muted">Send this URL to anyone who wants to book time with you.</p>
<code class="link">${location.origin}${base}</code>
<button class="primary" id="copy-link">Copy link</button>
</div>
<div class="card">
<h2>Next up</h2>
${upcoming.length === 0 ? `<p class="muted">No upcoming bookings.</p>` : `
<ul class="list">
${upcoming.map((b) => `<li>
<strong>${escapeHtml(b.guestName)}</strong>
<span>${escapeHtml(new Date(b.startTime).toLocaleString())}</span>
</li>`).join("")}
</ul>
`}
</div>
<div class="card">
<h2>Google Calendar</h2>
${this.gcal?.connected
? `<p>Connected as <strong>${escapeHtml(this.gcal.email || "(unknown)")}</strong>. Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}.</p>`
: `<p class="muted">Not connected. Bookings will be created without Google Calendar events.</p>`}
<button class="secondary" data-tab="calendar">Manage →</button>
</div>
<div class="card">
<h2>Availability</h2>
<p>${this.rules.filter(r => r.isActive).length} active weekly rules, ${this.overrides.length} date overrides.</p>
<button class="secondary" data-tab="availability">Configure →</button>
</div>
</div>
`;
}
private renderAvailability(): string {
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return `
<h2>Weekly availability rules</h2>
<p class="muted">Add one or more time windows per weekday. Leave a day empty to block it entirely.</p>
<table class="tbl">
<thead><tr><th>Day</th><th>Start</th><th>End</th><th>Active</th><th></th></tr></thead>
<tbody>
${this.rules.length === 0 ? `<tr><td colspan="5" class="muted">No rules yet. Add one below.</td></tr>` : this.rules
.slice()
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.startTime.localeCompare(b.startTime))
.map(r => `<tr data-rule-id="${r.id}">
<td>${DOW[r.dayOfWeek]}</td>
<td><input type="time" class="rule-start" value="${r.startTime}"></td>
<td><input type="time" class="rule-end" value="${r.endTime}"></td>
<td><input type="checkbox" class="rule-active" ${r.isActive ? "checked" : ""}></td>
<td><button class="danger rule-del">Delete</button></td>
</tr>`).join("")}
</tbody>
</table>
<form class="add-form" id="add-rule">
<select id="nr-dow">${DOW.map((d, i) => `<option value="${i}">${d}</option>`).join("")}</select>
<input type="time" id="nr-start" value="09:00">
<input type="time" id="nr-end" value="17:00">
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>Add rule</button>
</form>
<h2 style="margin-top:32px">Date overrides</h2>
<p class="muted">Block specific dates or set custom hours for a single day.</p>
<table class="tbl">
<thead><tr><th>Date</th><th>Blocked?</th><th>Custom window</th><th>Reason</th><th></th></tr></thead>
<tbody>
${this.overrides.length === 0 ? `<tr><td colspan="5" class="muted">No overrides.</td></tr>` : this.overrides
.slice()
.sort((a, b) => a.date.localeCompare(b.date))
.map(o => `<tr data-ov-id="${o.id}">
<td>${o.date}</td>
<td>${o.isBlocked ? "✓ blocked" : "custom"}</td>
<td>${o.isBlocked ? "—" : `${o.startTime || "?"} ${o.endTime || "?"}`}</td>
<td>${escapeHtml(o.reason || "")}</td>
<td><button class="danger ov-del">Delete</button></td>
</tr>`).join("")}
</tbody>
</table>
<form class="add-form" id="add-override">
<input type="date" id="no-date" required>
<label class="inline"><input type="checkbox" id="no-blocked" checked> Block day</label>
<input type="time" id="no-start" placeholder="from">
<input type="time" id="no-end" placeholder="to">
<input type="text" id="no-reason" placeholder="Reason (optional)">
<button class="primary" type="submit">Add override</button>
</form>
`;
}
private renderBookings(): string {
return `
<h2>All bookings</h2>
${this.bookings.length === 0 ? `<p class="muted">No bookings yet.</p>` : `
<table class="tbl">
<thead><tr><th>When</th><th>Guest</th><th>Email</th><th>Status</th><th>Attendees</th></tr></thead>
<tbody>
${this.bookings.map(b => `<tr>
<td>${escapeHtml(new Date(b.startTime).toLocaleString())}</td>
<td>${escapeHtml(b.guestName)}</td>
<td><a href="mailto:${escapeHtml(b.guestEmail)}">${escapeHtml(b.guestEmail)}</a></td>
<td><span class="badge badge-${b.status}">${b.status}</span></td>
<td>${b.attendeeCount}</td>
</tr>`).join("")}
</tbody>
</table>
`}
`;
}
private renderCalendar(): string {
const path = window.location.pathname;
const connectUrl = `/api/oauth/google/authorize?space=${encodeURIComponent(this.space)}&returnTo=rschedule%2Fadmin`;
const base = this.apiBase().replace(/\/api\/admin$/, "");
return `
<h2>Google Calendar</h2>
${this.gcal?.connected ? `
<p>Connected to Google as <strong>${escapeHtml(this.gcal.email || "(unknown)")}</strong>.</p>
<p class="muted">Calendar ID: <code>${escapeHtml(this.gcal.calendarIds[0] || "primary")}</code></p>
<p class="muted">Last sync: ${this.gcal.lastSyncAt ? new Date(this.gcal.lastSyncAt).toLocaleString() : "never"}
${this.gcal.lastSyncStatus ? ` · <span class="badge badge-${this.gcal.lastSyncStatus === "ok" ? "confirmed" : "cancelled"}">${this.gcal.lastSyncStatus}</span>` : ""}</p>
${this.gcal.lastSyncError ? `<p class="err-inline">${escapeHtml(this.gcal.lastSyncError)}</p>` : ""}
<div class="row-gap">
<button class="primary" id="sync-gcal" ${this.busy ? "disabled" : ""}>Sync now</button>
<button class="secondary" id="disconnect-gcal">Disconnect from rSchedule</button>
</div>
<p class="muted small">Tokens are shared across rSpace modules and live in the encrypted connections doc. Fully revoke the OAuth grant via <code>/api/oauth/google/disconnect?space=${escapeHtml(this.space)}</code>.</p>
` : `
<p class="muted">Connecting Google Calendar blocks bookings when you're busy on gcal and creates a calendar event for every confirmed booking.</p>
<a class="primary" href="${connectUrl}">Connect Google Calendar</a>
`}
`;
}
private renderSettings(): string {
const s = this.settings;
if (!s) return `<p class="muted">Loading…</p>`;
return `
<h2>Booking policies</h2>
<form id="settings-form" class="settings-form">
<label>Display name
<input name="displayName" value="${escapeHtml(s.displayName || "")}" placeholder="${escapeHtml(this.space)}">
</label>
<label>Contact email
<input name="email" type="email" value="${escapeHtml(s.email || "")}">
</label>
<label class="full">Booking page message
<textarea name="bookingMessage" rows="3">${escapeHtml(s.bookingMessage)}</textarea>
</label>
<label>Slot duration (min)
<input name="slotDurationMin" type="number" min="15" max="120" step="5" value="${s.slotDurationMin}">
</label>
<label>Timezone
<input name="timezone" value="${escapeHtml(s.timezone)}">
</label>
<label>Buffer before (min)
<input name="bufferBeforeMin" type="number" min="0" value="${s.bufferBeforeMin}">
</label>
<label>Buffer after (min)
<input name="bufferAfterMin" type="number" min="0" value="${s.bufferAfterMin}">
</label>
<label>Min notice (hours)
<input name="minNoticeHours" type="number" min="0" value="${s.minNoticeHours}">
</label>
<label>Max advance (days)
<input name="maxAdvanceDays" type="number" min="1" max="365" value="${s.maxAdvanceDays}">
</label>
<button class="primary" type="submit" ${this.busy ? "disabled" : ""}>${this.busy ? "Saving…" : "Save settings"}</button>
</form>
`;
}
private tabBtn(id: Tab, label: string): string {
return `<button class="tab ${this.tab === id ? "active" : ""}" data-tab="${id}">${label}</button>`;
}
private wire() {
const $ = (id: string) => this.shadow.getElementById(id);
this.shadow.querySelectorAll<HTMLElement>("[data-tab]").forEach((el) => {
el.addEventListener("click", () => {
this.tab = (el.dataset.tab as Tab) || "overview";
this.err = ""; this.info = "";
this.render();
});
});
// Copy link
$("copy-link")?.addEventListener("click", () => {
const base = this.apiBase().replace(/\/api\/admin$/, "");
navigator.clipboard?.writeText(`${location.origin}${base}`);
this.info = "Link copied.";
this.render();
});
// Rule handlers
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-rule-id]").forEach((row) => {
const id = row.dataset.ruleId!;
const start = row.querySelector<HTMLInputElement>(".rule-start");
const end = row.querySelector<HTMLInputElement>(".rule-end");
const active = row.querySelector<HTMLInputElement>(".rule-active");
start?.addEventListener("change", () => void this.updateRule(id, { startTime: start.value }));
end?.addEventListener("change", () => void this.updateRule(id, { endTime: end.value }));
active?.addEventListener("change", () => void this.updateRule(id, { isActive: active.checked }));
row.querySelector<HTMLButtonElement>(".rule-del")?.addEventListener("click", () => void this.deleteRule(id));
});
(this.shadow.getElementById("add-rule") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const dow = Number(($("nr-dow") as HTMLSelectElement).value);
const startTime = ($("nr-start") as HTMLInputElement).value;
const endTime = ($("nr-end") as HTMLInputElement).value;
void this.addRule({ dayOfWeek: dow, startTime, endTime, isActive: true });
});
// Override handlers
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-ov-id]").forEach((row) => {
const id = row.dataset.ovId!;
row.querySelector<HTMLButtonElement>(".ov-del")?.addEventListener("click", () => void this.deleteOverride(id));
});
(this.shadow.getElementById("add-override") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const date = ($("no-date") as HTMLInputElement).value;
const blocked = ($("no-blocked") as HTMLInputElement).checked;
const start = ($("no-start") as HTMLInputElement).value || null;
const end = ($("no-end") as HTMLInputElement).value || null;
const reason = ($("no-reason") as HTMLInputElement).value;
if (!date) return;
void this.addOverride({ date, isBlocked: blocked, startTime: start, endTime: end, reason });
});
// Gcal
$("sync-gcal")?.addEventListener("click", () => void this.syncGcal());
$("disconnect-gcal")?.addEventListener("click", () => void this.disconnectGcal());
// Timezone shift
$("tz-shift")?.addEventListener("click", () => void this.shiftTimezone());
// Invitations
this.shadow.querySelectorAll<HTMLTableRowElement>("tr[data-inv-id]").forEach((row) => {
const id = row.dataset.invId!;
row.querySelector<HTMLButtonElement>(".inv-accept")?.addEventListener("click", () => void this.respondToInvitation(id, "accepted"));
row.querySelector<HTMLButtonElement>(".inv-decline")?.addEventListener("click", () => void this.respondToInvitation(id, "declined"));
});
// Settings form
(this.shadow.getElementById("settings-form") as HTMLFormElement | null)?.addEventListener("submit", (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const patch: Partial<Settings> = {
displayName: String(fd.get("displayName") || ""),
email: String(fd.get("email") || ""),
bookingMessage: String(fd.get("bookingMessage") || ""),
slotDurationMin: Number(fd.get("slotDurationMin")),
timezone: String(fd.get("timezone") || "UTC"),
bufferBeforeMin: Number(fd.get("bufferBeforeMin")),
bufferAfterMin: Number(fd.get("bufferAfterMin")),
minNoticeHours: Number(fd.get("minNoticeHours")),
maxAdvanceDays: Number(fd.get("maxAdvanceDays")),
};
void this.saveSettings(patch);
});
}
}
function escapeHtml(s: string): string {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const STYLES = `
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:#0b1120; min-height:100vh; }
.wrap { max-width:1100px; margin:0 auto; padding:32px 24px; }
header h1 { margin:0 0 4px; font-size:1.5rem; }
.sub { color:#94a3b8; margin:0 0 20px; font-size:0.9rem; }
.err, .err-inline { padding:12px 14px; border:1px solid rgba(239,68,68,0.3); background:rgba(239,68,68,0.08); border-radius:8px; color:#fca5a5; margin-bottom:14px; font-size:0.9rem; }
.info { padding:10px 14px; border:1px solid rgba(34,197,94,0.3); background:rgba(34,197,94,0.08); border-radius:8px; color:#86efac; margin-bottom:14px; font-size:0.9rem; }
.unauth { padding:40px; text-align:center; border:1px dashed rgba(148,163,184,0.3); border-radius:10px; color:#94a3b8; margin-top:20px; }
.tabs { display:flex; gap:4px; border-bottom:1px solid rgba(148,163,184,0.12); margin-bottom:20px; overflow-x:auto; }
.tab { background:transparent; color:#94a3b8; border:0; padding:10px 16px; cursor:pointer; font-size:0.9rem; border-bottom:2px solid transparent; white-space:nowrap; }
.tab.active { color:#06b6d4; border-bottom-color:#06b6d4; }
.panel { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:12px; padding:24px; }
.panel h2 { margin:0 0 8px; font-size:1.05rem; color:#e2e8f0; }
.muted { color:#94a3b8; font-size:0.9rem; }
.small { font-size:0.8rem; }
.link { display:inline-block; background:rgba(6,182,212,0.08); padding:6px 10px; border-radius:6px; margin:6px 8px 6px 0; font-family:ui-monospace, monospace; color:#5eead4; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width:700px) { .grid2 { grid-template-columns:1fr; } }
.card { background:#0b1120; border:1px solid rgba(148,163,184,0.12); border-radius:10px; padding:20px; }
.card h2 { margin:0 0 8px; font-size:0.95rem; }
.list { list-style:none; padding:0; margin:0; }
.list li { display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px solid rgba(148,163,184,0.08); font-size:0.9rem; }
.list li:last-child { border-bottom:0; }
.primary { background:linear-gradient(to right,#06b6d4,#8b5cf6); color:#fff; border:0; padding:9px 16px; border-radius:7px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-block; font-size:0.9rem; }
.primary:disabled { opacity:0.6; cursor:default; }
.secondary { background:transparent; color:#06b6d4; border:1px solid rgba(6,182,212,0.4); padding:8px 14px; border-radius:7px; cursor:pointer; font-size:0.9rem; }
.secondary:hover { background:rgba(6,182,212,0.08); }
.danger { background:transparent; color:#fca5a5; border:1px solid rgba(239,68,68,0.3); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:0.82rem; }
.danger:hover { background:rgba(239,68,68,0.1); }
.row-gap { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
.tbl { width:100%; border-collapse:collapse; margin-top:12px; font-size:0.9rem; }
.tbl th, .tbl td { text-align:left; padding:8px 10px; border-bottom:1px solid rgba(148,163,184,0.1); }
.tbl th { color:#64748b; font-weight:500; font-size:0.8rem; }
.tbl input[type="time"], .tbl input[type="text"] { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:4px 8px; border-radius:5px; }
.add-form { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; align-items:center; }
.add-form input, .add-form select { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:6px 10px; border-radius:6px; font-size:0.9rem; }
.add-form .inline { display:flex; align-items:center; gap:6px; color:#cbd5e1; font-size:0.9rem; }
.settings-form { display:grid; grid-template-columns:1fr 1fr; gap:14px; max-width:800px; }
.settings-form label { display:flex; flex-direction:column; gap:4px; color:#cbd5e1; font-size:0.85rem; }
.settings-form label.full { grid-column:1 / -1; }
.settings-form input, .settings-form textarea { background:#0b1120; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); padding:8px 10px; border-radius:6px; font-size:0.9rem; font-family:inherit; }
.settings-form button[type="submit"] { grid-column:1 / -1; justify-self:start; }
.badge { padding:2px 8px; border-radius:99px; font-size:0.75rem; font-weight:500; }
.badge-confirmed, .badge-accepted, .badge-ok { background:rgba(34,197,94,0.15); color:#86efac; }
.badge-cancelled, .badge-declined, .badge-error { background:rgba(239,68,68,0.15); color:#fca5a5; }
.badge-completed { background:rgba(148,163,184,0.15); color:#cbd5e1; }
.badge-invited { background:rgba(6,182,212,0.15); color:#5eead4; }
.tz-banner { background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.3); color:#fcd34d; padding:12px 16px; border-radius:8px; margin-bottom:14px; font-size:0.9rem; display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
.small { font-size:0.82rem; padding:5px 10px; }
code { background:rgba(148,163,184,0.1); padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; font-size:0.85rem; color:#e2e8f0; }
`;
customElements.define("folk-schedule-admin", FolkScheduleAdmin);
export {};