Files
NextChair/manager/src/app/dashboard/settings/operator-settings-form.tsx
T
Stefan Lazic 68a62e02a4
CI / ci (push) Canceled after 0s
Remaining features
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00

180 lines
7.6 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect } from "react";
import type { OperatorSettings } from "@/lib/global-settings";
import type { SchedulerStatus } from "@/lib/scheduler-status";
import {
saveOperatorSettingsAction,
sendTestAlertAction,
type SaveOperatorSettingsState,
type SendTestAlertState,
} from "./actions";
const saveInitialState: SaveOperatorSettingsState = { error: null, saved: false };
const testInitialState: SendTestAlertState = { result: null };
function Toggle({ name, label, hint, defaultChecked }: { name: string; label: string; hint?: string; defaultChecked: boolean }) {
return (
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<span>
{label}
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
</label>
);
}
export function OperatorSettingsForm({ initial, status }: { initial: OperatorSettings; status: SchedulerStatus }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveOperatorSettingsAction, saveInitialState);
const [testState, testAction, isTesting] = useActionState(sendTestAlertAction, testInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Monitoring &amp; scheduler</h2>
{initial.alertEmail ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Alerts on</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Alerts off</span>
)}
</div>
<p className="text-sm text-muted">
The Manager calls every business&apos;s notification cron, checks its <span className="font-mono">/api/health</span> and runs scheduled
backups itself. Alerts are emailed with the SMTP settings above; a business must fail two checks in a row before you hear about it.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div>
<label htmlFor="alertEmail" className="block text-sm font-medium text-muted">
Operator alert email
</label>
<input
id="alertEmail"
name="alertEmail"
type="email"
defaultValue={initial.alertEmail}
placeholder="you@example.com — blank turns alerts off"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<Toggle name="alertOnHealth" label="Email when a business goes degraded or down" defaultChecked={initial.alertOnHealth} />
<Toggle name="alertOnRecovery" label="Email when it recovers" defaultChecked={initial.alertOnRecovery} />
<Toggle name="dailyReminder" label="Remind me daily while it is still bad" defaultChecked={initial.dailyReminder} />
<Toggle name="alertOnBackupFailure" label="Email when a scheduled backup fails" defaultChecked={initial.alertOnBackupFailure} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="notificationsIntervalSeconds" className="block text-sm font-medium text-muted">
Notification cron every (seconds)
</label>
<input
id="notificationsIntervalSeconds"
name="notificationsIntervalSeconds"
type="number"
min={10}
max={3600}
required
defaultValue={initial.notificationsIntervalSeconds}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="healthIntervalMinutes" className="block text-sm font-medium text-muted">
Health check every (minutes)
</label>
<input
id="healthIntervalMinutes"
name="healthIntervalMinutes"
type="number"
min={1}
max={60}
required
defaultValue={initial.healthIntervalMinutes}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
{saveState.rollout && saveState.rollout.length > 0 && (
<div className="text-sm">
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
<ul className="mt-1 space-y-0.5">
{saveState.rollout.map((r) => (
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={testAction}
disabled={isTesting || !initial.alertEmail}
title={!initial.alertEmail ? "Save an alert email first" : "Sends to the saved address"}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isTesting ? "Sending…" : "Send test alert"}
</button>
</div>
{testState.result && (
<p role="status" className={`text-sm ${testState.result.ok ? "text-green-700" : "font-medium text-danger"}`}>
{testState.result.message}
</p>
)}
</form>
<div className="rounded-md border border-line bg-surface p-5">
<h3 className="text-sm font-semibold">Scheduler status</h3>
<p className="text-xs text-muted">
Last tick: {status.heartbeatAt ? new Date(status.heartbeatAt).toLocaleString() : "never (scheduler not running?)"}
</p>
{status.rows.length > 0 && (
<table className="mt-3 w-full text-left text-xs">
<thead className="text-muted">
<tr>
<th className="py-1 pr-3 font-medium">Task</th>
<th className="py-1 pr-3 font-medium">Last run</th>
<th className="py-1 font-medium">Result</th>
</tr>
</thead>
<tbody>
{status.rows.map(({ key, state }) => (
<tr key={key} className="border-t border-line">
<td className="py-1 pr-3 font-mono">{key}</td>
<td className="py-1 pr-3">{state.lastFinishedAt ? new Date(state.lastFinishedAt).toLocaleString() : "running / never"}</td>
<td className={`py-1 ${state.lastOk === false ? "text-danger" : ""}`}>{state.lastMessage ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}