Files
temetro/frontend/components/patients/transfer-patient-dialog.tsx
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

150 lines
4.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { ROLE_LABELS } from "@/lib/access";
import { type Patient, transferPatient } from "@/lib/patients";
import { listProviders, type Provider } from "@/lib/staff";
import { notify } from "@/lib/toast";
const selectClass =
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
type Props = {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
onTransferred: (patient: Patient) => void;
};
// Reassign a patient to another clinician. The new provider becomes the
// patient's primary provider (and PCP label), which moves the chart into their
// panel under per-doctor visibility.
export function TransferPatientDialog({
patient,
open,
onOpenChange,
onTransferred,
}: Props) {
const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState(patient.primaryProviderId ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous patient's provider.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setProviderId(patient.primaryProviderId ?? "");
setError(null);
}
}
useEffect(() => {
if (!open) return;
let active = true;
listProviders()
.then((list) => active && setProviders(list))
.catch(() => active && setProviders([]));
return () => {
active = false;
};
}, [open, patient.primaryProviderId]);
const submit = async () => {
if (!providerId || submitting) return;
setSubmitting(true);
setError(null);
try {
const updated = await transferPatient(patient.fileNumber, providerId);
onTransferred(updated);
notify.success(
t("patients.transfer.successTitle"),
t("patients.transfer.successBody", {
name: updated.name,
provider: updated.pcp,
}),
);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("patients.transfer.error");
setError(message);
notify.error(t("patients.transfer.errorTitle"), message);
} finally {
setSubmitting(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("patients.transfer.title")}</DialogTitle>
<DialogDescription>
{t("patients.transfer.description", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-3">
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<label className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("patients.transfer.providerLabel")}
</span>
<select
className={selectClass}
onChange={(e) => setProviderId(e.target.value)}
value={providerId}
>
<option value="">{t("patients.transfer.choose")}</option>
{providers.map((p) => (
<option key={p.userId} value={p.userId}>
{p.name} ·{" "}
{ROLE_LABELS[p.role as keyof typeof ROLE_LABELS] ?? p.role}
</option>
))}
</select>
</label>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patients.transfer.cancel")}
</DialogClose>
<Button
disabled={
submitting ||
!providerId ||
providerId === patient.primaryProviderId
}
onClick={submit}
type="button"
>
{submitting
? t("patients.transfer.transferring")
: t("patients.transfer.confirm")}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}