mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 19:06:52 +00:00
fix: clinic creation gating, analysis line charts, username + task assignee, delete confirm
- Hide "Create clinic" (sidebar footer) for non-admins; only owner/admin can spin up additional clinics. Onboarding for brand-new users is unaffected. - Analysis: drop the bar charts; show line charts (Sparkline) inside KPI cards that open a detail dialog with the full chart + per-point breakdown. - Add Team Member: validate the username client-side (no spaces; letters, numbers, dots, underscores) with a clear warning + field hint. - Tasks: New Task now has an Assignee selector (Myself / Other → department). Tasks are visible to the department they're assigned to (or the creator), and show who created them. Backend adds assignee_role + created_by_name with visibility filtering in listTasks; owners/admins see all. - Care team: removing a member now asks for confirmation first (dialog) and surfaces success/failure + refreshes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,9 @@ import { notify } from "@/lib/toast";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
const MIN_USERNAME = 3;
|
||||
// Mirrors the backend rule (backend/src/routes/staff.ts): letters, numbers,
|
||||
// dots and underscores only — notably no spaces.
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_.]+$/;
|
||||
|
||||
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";
|
||||
@@ -77,10 +80,15 @@ export function AddStaffDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
}
|
||||
|
||||
// Step 2 → create the account.
|
||||
if (username.trim().length < MIN_USERNAME) {
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < MIN_USERNAME) {
|
||||
setError(t("settings.careTeam.add.usernameTooShort", { count: MIN_USERNAME }));
|
||||
return;
|
||||
}
|
||||
if (!USERNAME_RE.test(trimmedUsername)) {
|
||||
setError(t("settings.careTeam.add.usernameInvalid"));
|
||||
return;
|
||||
}
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setError(t("settings.careTeam.add.passwordTooShort", { count: MIN_PASSWORD }));
|
||||
return;
|
||||
@@ -93,7 +101,7 @@ export function AddStaffDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
role,
|
||||
username: username.trim(),
|
||||
username: trimmedUsername,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
@@ -101,7 +109,7 @@ export function AddStaffDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
t("settings.careTeam.add.createdTitle"),
|
||||
t("settings.careTeam.add.createdBody", {
|
||||
name: name.trim(),
|
||||
username: username.trim().toLowerCase(),
|
||||
username: trimmedUsername.toLowerCase(),
|
||||
}),
|
||||
);
|
||||
onCreated?.();
|
||||
@@ -183,6 +191,9 @@ export function AddStaffDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
required
|
||||
value={username}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{t("settings.careTeam.add.usernameHint")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field className="w-full">
|
||||
<FieldLabel htmlFor="staff-password">
|
||||
|
||||
@@ -12,9 +12,19 @@ import {
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ROLE_LABELS } from "@/lib/access";
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// One row of /api/staff — clinic members joined to their user record (incl. the
|
||||
// username admin-provisioned staff sign in with).
|
||||
@@ -52,6 +62,8 @@ export function CareTeamPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -75,8 +87,27 @@ export function CareTeamPanel() {
|
||||
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const removeMember = async (memberId: string) => {
|
||||
await authClient.organization.removeMember({ memberIdOrEmail: memberId });
|
||||
const confirmRemove = async () => {
|
||||
if (!pendingRemove || removing) return;
|
||||
setRemoving(true);
|
||||
const { error: err } = await authClient.organization.removeMember({
|
||||
memberIdOrEmail: pendingRemove.id,
|
||||
});
|
||||
setRemoving(false);
|
||||
if (err) {
|
||||
notify.error(
|
||||
t("settings.careTeam.remove.failedTitle"),
|
||||
err.message ?? t("settings.careTeam.remove.failedBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
notify.success(
|
||||
t("settings.careTeam.remove.removedTitle"),
|
||||
t("settings.careTeam.remove.removedBody", {
|
||||
name: pendingRemove.name ?? pendingRemove.email ?? "",
|
||||
}),
|
||||
);
|
||||
setPendingRemove(null);
|
||||
void load();
|
||||
};
|
||||
|
||||
@@ -139,7 +170,7 @@ export function CareTeamPanel() {
|
||||
{canManage && !isSelf && m.role !== "owner" && (
|
||||
<Button
|
||||
aria-label={t("settings.careTeam.removeMember")}
|
||||
onClick={() => removeMember(m.id)}
|
||||
onClick={() => setPendingRemove(m)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -160,6 +191,41 @@ export function CareTeamPanel() {
|
||||
open={adding}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm before removing a member — destructive and not reversible. */}
|
||||
<Dialog
|
||||
onOpenChange={(o) => !o && setPendingRemove(null)}
|
||||
open={pendingRemove !== null}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.careTeam.remove.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.careTeam.remove.description", {
|
||||
name:
|
||||
pendingRemove?.name ??
|
||||
pendingRemove?.email ??
|
||||
t("settings.careTeam.remove.thisMember"),
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("settings.careTeam.remove.cancel")}
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={removing}
|
||||
onClick={confirmRemove}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{removing
|
||||
? t("settings.careTeam.remove.removing")
|
||||
: t("settings.careTeam.remove.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user