- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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.device_no ? (
|
||||
<Badge variant="outline">
|
||||
<span className="font-mono">
|
||||
{member.device_no}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user