Files
temetro/frontend/components/meetings/schedule-meeting-dialog.tsx
T
Khalid Abdi e8f3ed9ffe frontend: clear the 99 lint errors
`npm run lint` reported 99 errors and 26 warnings across 65 files and
had presumably been failing for a while — next.config.ts sets
eslint.ignoreDuringBuilds, so the build never surfaced it.

68 were in vendored code: components/charts and components/ai-elements,
pulled from upstream registries. Re-linting those reports upstream's
style back at us, and "fixing" them means diverging and eating conflicts
on every update — ai-elements already has exactly this carve-out on the
TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry
files in components/ui (carousel publishes its api from an effect; the
sidebar skeleton picks a random width).

The other 31 were ours, nearly all react-hooks/set-state-in-effect on
the same shape: an effect that re-seeds form state when a dialog opens or
a selection changes. Moved to render-phase adjustment, which is both what
React recommends and a real fix — the effect version paints one frame of
the *previous* record's values before correcting itself. Two carried
sharper bugs: the employee dialog could keep a typed password across a
switch to another member, and use-wallet-sync could carry `linked` over
to a newly-selected patient, briefly offering to push a record to
someone else's wallet.

The rest: useIsMobile and speech-support detection become
useSyncExternalStore (correct on first paint, no mount flash); refs
mirroring state are written in effects rather than during render; the
care-team fetch moves into its effect behind a reload key, dropping an
exhaustive-deps suppression.

Two effects in chat-panel keep the rule disabled with a reason. Both are
what effects are for: draining the queued-message buffer when the
transport goes idle, and resolving ?thread from the URL — the latter
mints an id with nanoid(), so moving it into render would just trade this
error for a purity one.

Also removes a dead /explore-era import and unused directives found on
the way. lint now exits 0, with the ai-elements tsc carve-out unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:54:37 +03:00

210 lines
7.0 KiB
TypeScript

"use client";
import { Check } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { createMeetingEvent } from "@/lib/meetings";
import { listClinicMembers, type Participant } from "@/lib/messages";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
}
export function ScheduleMeetingDialog({
open,
onOpenChange,
defaultDate,
defaultParticipants,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultDate?: string; // YYYY-MM-DD
defaultParticipants?: string[]; // member ids to preselect
onCreated: () => void;
}) {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
const [title, setTitle] = useState("");
const [date, setDate] = useState(defaultDate ?? "");
const [time, setTime] = useState("09:00");
const [members, setMembers] = useState<Participant[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous meeting's title or participants.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
}
}
useEffect(() => {
if (!open) return;
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
}, [open, defaultDate, defaultParticipants]);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!(title.trim() && date && time) || saving) return;
setSaving(true);
try {
await createMeetingEvent({
title: title.trim(),
date,
time,
participants: [...picked],
});
onCreated();
onOpenChange(false);
} catch {
notify.error(
t("meetings.schedule.failedTitle"),
t("meetings.schedule.failedBody"),
);
} finally {
setSaving(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("meetings.schedule.title")}</DialogTitle>
<DialogDescription>
{t("meetings.schedule.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-title">
{t("meetings.schedule.titleLabel")}
</label>
<Input
id="meeting-title"
onChange={(e) => setTitle(e.target.value)}
placeholder={t("meetings.schedule.titlePlaceholder")}
value={title}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-date">
{t("meetings.schedule.date")}
</label>
<Input
id="meeting-date"
onChange={(e) => setDate(e.target.value)}
type="date"
value={date}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-medium text-sm" htmlFor="meeting-time">
{t("meetings.schedule.time")}
</label>
<Input
id="meeting-time"
onChange={(e) => setTime(e.target.value)}
type="time"
value={time}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="font-medium text-sm">
{t("meetings.schedule.participants")}
</span>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto rounded-2xl border p-1">
{members.filter((m) => m.id !== myId).length === 0 ? (
<p className="px-2 py-3 text-center text-muted-foreground text-sm">
{t("meetings.invite.noMembers")}
</p>
) : (
members
.filter((m) => m.id !== myId)
.map((m) => {
const on = picked.has(m.id);
return (
<button
className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50",
on && "bg-accent",
)}
key={m.id}
onClick={() => toggle(m.id)}
type="button"
>
<Avatar className="size-7">
<AvatarFallback className="text-xs">
{initials(m.name)}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm">
{m.name}
</span>
{on && <Check className="size-4 text-primary" />}
</button>
);
})
)}
</div>
</div>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("meetings.cancel")}
</DialogClose>
<Button
disabled={!(title.trim() && date && time) || saving}
type="submit"
>
{saving ? t("meetings.schedule.saving") : t("meetings.schedule.save")}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}