mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
e8f3ed9ffe
`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>
210 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|