Files
hi-frontend/apps/admin/src/sections/user/family/family-detail-sheet.tsx
T
shanshanzhong147 5a493fce67
Build and Release / Build (push) Has been cancelled
fix: 修复订阅跳转引号问题 + 设备组显示设备类型 + 统一用户名显示
- 去掉所有 Link search 中的 String() 包装(7处),修复 URL 引号问题
- View Owner 改用 useNavigate 强制导航,解决同页面跳转不生效
- 设备组详情成员列表添加 auth_type Badge 和 device_type Badge
- 设备组列表 owner 列统一为 [AUTH_TYPE Badge] + identifier 格式
- UserDetail 组件统一设备标识逻辑,去掉 shortenDeviceIdentifier

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 21:36:12 -07:00

364 lines
14 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import {
dissolveFamily,
getFamilyDetail,
removeFamilyMember,
updateFamilyMaxMembers,
} from "@workspace/ui/services/admin/user";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import {
getFamilyJoinSourceLabel,
getFamilyMemberStatusLabel,
getFamilyRoleLabel,
getFamilyStatusLabel,
isFamilyMemberStatusActive,
isFamilyRoleOwner,
isFamilyStatusActive,
} from "./enums";
interface FamilyDetailSheetProps {
familyId?: number;
trigger: ReactNode;
onChanged?: () => void;
}
export function FamilyDetailSheet({
familyId,
trigger,
onChanged,
}: Readonly<FamilyDetailSheetProps>) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const [maxMembersInput, setMaxMembersInput] = useState("");
const queryClient = useQueryClient();
const validFamilyId = Number(familyId || 0);
const { data, isLoading, refetch } = useQuery({
enabled: open && validFamilyId > 0,
queryKey: ["familyDetail", validFamilyId],
queryFn: async () => {
const { data } = await getFamilyDetail({ id: validFamilyId });
return data.data;
},
});
useEffect(() => {
if (!(open && data?.summary)) return;
setMaxMembersInput(String(data.summary.max_members));
}, [data?.summary, open]);
const invalidateAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["familyList"] }),
queryClient.invalidateQueries({
queryKey: ["familyDetail", validFamilyId],
}),
queryClient.invalidateQueries({ queryKey: ["getUserList"] }),
]);
onChanged?.();
};
const updateMaxMutation = useMutation({
mutationFn: async (maxMembers: number) =>
updateFamilyMaxMembers({
family_id: validFamilyId,
max_members: maxMembers,
}),
onSuccess: async () => {
toast.success(t("updateSuccess", "Updated successfully"));
await invalidateAll();
await refetch();
},
});
const removeMemberMutation = useMutation({
mutationFn: async (userId: number) =>
removeFamilyMember({ family_id: validFamilyId, user_id: userId }),
onSuccess: async () => {
toast.success(t("removeSuccess", "Removed successfully"));
await invalidateAll();
await refetch();
},
});
const dissolveMutation = useMutation({
mutationFn: async () => dissolveFamily({ family_id: validFamilyId }),
onSuccess: async () => {
toast.success(t("familyDissolved", "Family dissolved"));
await invalidateAll();
await refetch();
},
});
const canDissolve = isFamilyStatusActive(data?.summary.status);
const summaryItems = useMemo(() => {
if (!data?.summary) return [];
return [
{ label: t("familyId", "Family ID"), value: data.summary.family_id },
{ label: t("owner", "Owner"), value: data.summary.owner_identifier },
{ label: t("userId", "User ID"), value: data.summary.owner_user_id },
{
label: t("familyStatus", "Family Status"),
value: getFamilyStatusLabel(t, data.summary.status),
},
{
label: t("memberCount", "Member Count"),
value: `${data.summary.active_member_count}/${data.summary.max_members}`,
},
{
label: t("createdAt", "Created At"),
value: formatDate(data.summary.created_at),
},
{
label: t("updatedAt", "Updated At"),
value: formatDate(data.summary.updated_at),
},
];
}, [data?.summary, t]);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="w-[920px] max-w-full md:max-w-6xl" side="right">
<SheetHeader>
<SheetTitle>
{t("familyDetail", "Family Detail")}
{validFamilyId ? ` · ID: ${validFamilyId}` : ""}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-120px)] pr-2">
{isLoading ? (
<div className="p-4">{t("loading", "Loading...")}</div>
) : data ? (
<div className="space-y-4 p-2">
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familySummary", "Family Summary")}
</h3>
<div className="grid grid-cols-2 gap-3">
{summaryItems.map((item) => (
<div
className="flex items-center justify-between rounded bg-muted/40 px-3 py-2"
key={item.label}
>
<span className="text-muted-foreground text-xs">
{item.label}
</span>
<span className="font-medium text-sm">{item.value}</span>
</div>
))}
</div>
</div>
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familyActions", "Family Actions")}
</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="w-40">
<EnhancedInput
onValueChange={(value) => {
setMaxMembersInput(value);
}}
type="number"
value={maxMembersInput}
/>
</div>
<Button
disabled={updateMaxMutation.isPending}
onClick={() => {
const maxMembers = Number(maxMembersInput);
if (
!maxMembers ||
Number.isNaN(maxMembers) ||
maxMembers <= 0
) {
toast.error(
t("familyInvalidMaxMembers", "Invalid max members")
);
return;
}
if (
data.summary.active_member_count &&
maxMembers < data.summary.active_member_count
) {
toast.error(
t(
"familyMaxMembersTooSmall",
"Max members cannot be lower than active member count"
)
);
return;
}
updateMaxMutation.mutate(maxMembers);
}}
variant="secondary"
>
{t("familyUpdateMaxMembers", "Update Max Members")}
</Button>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"familyDissolveDescription",
"This will dissolve the family and remove all active members."
)}
onConfirm={async () => {
await dissolveMutation.mutateAsync();
}}
title={t(
"familyConfirmDissolve",
"Confirm Dissolve Family"
)}
trigger={
<Button
disabled={!canDissolve || dissolveMutation.isPending}
variant="destructive"
>
{t("familyDissolve", "Dissolve Family")}
</Button>
}
/>
</div>
</div>
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familyMembers", "Family Members")}
</h3>
<div className="space-y-2">
{data.members?.length ? (
data.members.map((member) => {
const canRemove =
isFamilyStatusActive(data.summary.status) &&
!isFamilyRoleOwner(member.role_name) &&
isFamilyMemberStatusActive(member.status_name);
return (
<div
className="flex items-center justify-between rounded border px-3 py-2"
key={`${member.user_id}-${member.joined_at}`}
>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<Badge variant="outline">
ID: {member.user_id}
</Badge>
{member.auth_type ? (
<Badge className="uppercase">
{member.auth_type}
</Badge>
) : null}
{member.device_no ? (
<Badge variant="outline">
<span className="font-mono">
{member.device_no}
</span>
</Badge>
) : null}
{member.device_type ? (
<Badge variant="secondary">
{member.device_type}
</Badge>
) : null}
<span>
{member.auth_type === "device"
? member.device_no || member.identifier
: member.identifier}
</span>
<Badge>
{getFamilyRoleLabel(t, member.role_name)}
</Badge>
<Badge
variant={
isFamilyMemberStatusActive(member.status_name)
? "default"
: "destructive"
}
>
{getFamilyMemberStatusLabel(
t,
member.status_name
)}
</Badge>
</div>
<div className="text-muted-foreground">
{t("familyJoinSource", "Join Source")}:{" "}
{getFamilyJoinSourceLabel(t, member.join_source)}{" "}
· {t("familyJoinedAt", "Joined At")}:{" "}
{member.joined_at
? formatDate(member.joined_at)
: "--"}{" "}
· {t("familyLeftAt", "Left At")}:{" "}
{member.left_at
? formatDate(member.left_at)
: "--"}
</div>
</div>
{canRemove ? (
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"familyRemoveMemberDescription",
"This will remove the member from the active family."
)}
onConfirm={async () => {
await removeMemberMutation.mutateAsync(
member.user_id
);
}}
title={t(
"familyConfirmRemoveMember",
"Confirm Remove Member"
)}
trigger={
<Button
disabled={removeMemberMutation.isPending}
size="sm"
variant="destructive"
>
{t("remove", "Remove")}
</Button>
}
/>
) : null}
</div>
);
})
) : (
<div className="text-muted-foreground text-sm">
{t("familyNoMembers", "No members")}
</div>
)}
</div>
</div>
</div>
) : (
<div className="p-4 text-muted-foreground">
{t("familyNoData", "No family data")}
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}