627 lines
28 KiB
TypeScript
627 lines
28 KiB
TypeScript
/**
|
||
* <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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 {};
|