feat(team-calc) : add character-wide team import
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/lib/catalog/sync";
|
||||
import {
|
||||
getAdminTeamImportGuide,
|
||||
listAdminTeamImportsForCharacter,
|
||||
listAdminTeamImportGuides,
|
||||
} from "@/lib/guides/queries";
|
||||
|
||||
@@ -130,6 +131,23 @@ export async function loadTeamImportGuide(guideId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTeamImportsForCharacter(currentGuideId: string, characterKey: string) {
|
||||
await requireAdmin();
|
||||
const guideId = z.string().uuid().safeParse(currentGuideId);
|
||||
const character = z.string().trim().min(1).max(64).safeParse(characterKey);
|
||||
if (!guideId.success || !character.success) {
|
||||
return { status: "error" as const, message: "Guide หรือตัวละครไม่ถูกต้อง" };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
status: "success" as const,
|
||||
teams: await listAdminTeamImportsForCharacter(guideId.data, character.data),
|
||||
};
|
||||
} catch {
|
||||
return { status: "error" as const, message: "โหลด Team จาก Guide อื่นไม่สำเร็จ" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGuideExtra(guideId: string, input: unknown) {
|
||||
const admin = await requireAdmin();
|
||||
try {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
saveConstellationSection,
|
||||
saveGuideExtra,
|
||||
listTeamImportGuides,
|
||||
listTeamImportsForCharacter,
|
||||
loadTeamImportGuide,
|
||||
saveTeamSection,
|
||||
saveWeaponSection,
|
||||
@@ -131,6 +132,7 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -172,6 +174,7 @@ import {
|
||||
applyPresetFields,
|
||||
applyTeamFields,
|
||||
cloneTeamImports,
|
||||
filterTeamImportsByName,
|
||||
findInvalidTeamCatalogReference,
|
||||
findUnsupportedTeamWeapon,
|
||||
limitPresets,
|
||||
@@ -2628,6 +2631,103 @@ type TeamImportGuide = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type TeamEverywhereImport = {
|
||||
id: string;
|
||||
title: string;
|
||||
rotationSeconds: string;
|
||||
combo: string;
|
||||
guideName: string;
|
||||
conditionName: string | null;
|
||||
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
|
||||
};
|
||||
|
||||
function TeamImportPreviewCard({
|
||||
team,
|
||||
characters,
|
||||
weapons,
|
||||
artifacts,
|
||||
checked,
|
||||
checkboxId,
|
||||
onCheckedChange,
|
||||
sourceLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
team: TeamImportGuide["groups"][number]["teams"][number];
|
||||
characters: CatalogItem[];
|
||||
weapons: CatalogItem[];
|
||||
artifacts: CatalogItem[];
|
||||
checked: boolean;
|
||||
checkboxId: string;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
sourceLabel?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
|
||||
return (
|
||||
<Card className={cn(checked && "ring-2 ring-primary")}>
|
||||
<CardHeader>
|
||||
<CardTitle>{team.title}</CardTitle>
|
||||
<CardDescription>โรเทชั่น {seconds} วิ{sourceLabel ? ` · ${sourceLabel}` : ""}</CardDescription>
|
||||
<CardAction>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id={checkboxId} checked={checked} disabled={disabled} onCheckedChange={(value) => onCheckedChange(value === true)} />
|
||||
<FieldLabel htmlFor={checkboxId}>{disabled ? "ชื่อซ้ำ" : "เลือก"}</FieldLabel>
|
||||
</Field>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{team.members.map((member, index) => {
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const weapon = weapons.find((item) => item.key === member.weaponKey);
|
||||
const artifact = artifacts.find((item) => item.key === member.artifactKey);
|
||||
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
|
||||
return (
|
||||
<div key={`${member.characterKey}-${index}`} className="min-w-0 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", characterRarityGradientClass(member.characterKey, isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
|
||||
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{weapon ? (
|
||||
<div className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(weapon.rarity))}>
|
||||
<Image src={weapon.imageUrl} alt={weapon.name} fill sizes="28px" className="object-contain p-0.5" />
|
||||
</div>
|
||||
) : null}
|
||||
{artifact || secondArtifact ? (
|
||||
<TeamArtifactIcon
|
||||
first={artifact ? { ...artifact, rarity: artifact.rarity ?? null } : null}
|
||||
second={secondArtifact ? { ...secondArtifact, rarity: secondArtifact.rarity ?? null } : null}
|
||||
sizes="28px"
|
||||
className="size-7 shrink-0 scale-100"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-1">
|
||||
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
|
||||
<Badge variant="secondary">{teamRefinementLabel(member.refinement)}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
|
||||
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
|
||||
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
|
||||
</div>
|
||||
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamEditor({
|
||||
guide,
|
||||
characters,
|
||||
@@ -2674,6 +2774,15 @@ function TeamEditor({
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importFiles, setImportFiles] = useState<File[]>([]);
|
||||
const [guideImportOpen, setGuideImportOpen] = useState(false);
|
||||
const [everywhereImportOpen, setEverywhereImportOpen] = useState(false);
|
||||
const everywhereSearchId = useRef(0);
|
||||
const [everywhereCharacterKey, setEverywhereCharacterKey] = useState("");
|
||||
const [everywhereSearchedKey, setEverywhereSearchedKey] = useState("");
|
||||
const [everywhereTeams, setEverywhereTeams] = useState<TeamEverywhereImport[]>([]);
|
||||
const [everywhereLoading, setEverywhereLoading] = useState(false);
|
||||
const [everywhereError, setEverywhereError] = useState<string | null>(null);
|
||||
const [everywhereSelectedIds, setEverywhereSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [ignoreSameName, setIgnoreSameName] = useState(true);
|
||||
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; coverUrl: string }> | null>(null);
|
||||
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
|
||||
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
|
||||
@@ -2737,6 +2846,12 @@ function TeamEditor({
|
||||
const filteredTeams = teamSearch.items;
|
||||
const filteredImportTeams = importTeamSearch.items;
|
||||
const filteredImportGuides = importGuideSearch.items;
|
||||
const eligibleEverywhereTeams = filterTeamImportsByName(teams, everywhereTeams, ignoreSameName);
|
||||
const eligibleEverywhereIds = new Set(eligibleEverywhereTeams.map((team) => team.id));
|
||||
const selectedEverywhereTeams = eligibleEverywhereTeams.filter((team) => everywhereSelectedIds.has(team.id));
|
||||
const allEverywhereTeamsSelected = eligibleEverywhereTeams.length > 0
|
||||
&& eligibleEverywhereTeams.every((team) => everywhereSelectedIds.has(team.id));
|
||||
const availableTeamSlots = 30 - teams.length;
|
||||
const value = {
|
||||
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, iconMediaId: group.iconMediaId, teams: group.teams.map((team) => ({
|
||||
title: team.title,
|
||||
@@ -3055,6 +3170,54 @@ function TeamEditor({
|
||||
if (result.status === "success") setImportGuides(result.guides);
|
||||
else setGuideImportError(result.message);
|
||||
};
|
||||
const openEverywhereImport = () => {
|
||||
everywhereSearchId.current += 1;
|
||||
setImportOpen(false);
|
||||
setEverywhereImportOpen(true);
|
||||
setEverywhereCharacterKey("");
|
||||
setEverywhereSearchedKey("");
|
||||
setEverywhereTeams([]);
|
||||
setEverywhereSelectedIds(new Set());
|
||||
setIgnoreSameName(true);
|
||||
setEverywhereError(null);
|
||||
setEverywhereLoading(false);
|
||||
};
|
||||
const searchEverywhereTeams = async (characterKey: string) => {
|
||||
const searchId = ++everywhereSearchId.current;
|
||||
setEverywhereSearchedKey("");
|
||||
setEverywhereTeams([]);
|
||||
setEverywhereSelectedIds(new Set());
|
||||
setEverywhereError(null);
|
||||
setEverywhereLoading(true);
|
||||
const result = await listTeamImportsForCharacter(guide.id, characterKey);
|
||||
if (searchId !== everywhereSearchId.current) return;
|
||||
setEverywhereLoading(false);
|
||||
if (result.status === "success") {
|
||||
setEverywhereTeams(result.teams);
|
||||
setEverywhereSearchedKey(characterKey);
|
||||
} else setEverywhereError(result.message);
|
||||
};
|
||||
const importEverywhereTeams = (sourceTeams: TeamEverywhereImport[]) => {
|
||||
if (!sourceTeams.length) return;
|
||||
const importedTeams = sourceTeams.map((team) => ({
|
||||
title: team.title,
|
||||
rotationSeconds: team.rotationSeconds,
|
||||
combo: team.combo,
|
||||
members: team.members.map((member) => ({
|
||||
characterKey: member.characterKey,
|
||||
weaponKey: member.weaponKey,
|
||||
artifactKey: member.artifactKey,
|
||||
secondArtifactKey: member.secondArtifactKey,
|
||||
constellation: member.constellation,
|
||||
refinement: member.refinement,
|
||||
damage: member.damage,
|
||||
})),
|
||||
}));
|
||||
if (!appendImportedTeams(importedTeams)) return;
|
||||
setEverywhereImportOpen(false);
|
||||
setEverywhereSelectedIds(new Set());
|
||||
toast.success(`Import ${importedTeams.length} Team แล้ว`);
|
||||
};
|
||||
const enterImportGuide = async (guideId: string) => {
|
||||
setGuideImportError(null);
|
||||
setGuideImportLoading("teams");
|
||||
@@ -3772,6 +3935,18 @@ function TeamEditor({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Import Everywhere</CardTitle>
|
||||
<CardDescription>เลือกตัวละคร แล้วค้นหา Team จาก Guide อื่นที่มีตัวละครนั้นอยู่ในทีม</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button type="button" variant="outline" onClick={openEverywhereImport}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
เลือกตัวละคร
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>เลือกจาก Guide</CardTitle>
|
||||
@@ -3815,6 +3990,117 @@ function TeamEditor({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={everywhereImportOpen} onOpenChange={(open) => {
|
||||
setEverywhereImportOpen(open);
|
||||
if (!open) {
|
||||
everywhereSearchId.current += 1;
|
||||
setEverywhereLoading(false);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Everywhere</DialogTitle>
|
||||
<DialogDescription>
|
||||
เลือกตัวละครเพื่อค้นหา Team จาก Guide อื่น แล้วดูตัวอย่างก่อนนำเข้า Condition ปัจจุบัน
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<Field>
|
||||
<FieldLabel>ตัวละครใน Team</FieldLabel>
|
||||
<CatalogDropdown
|
||||
items={characters}
|
||||
value={everywhereCharacterKey}
|
||||
onValueChange={(key) => {
|
||||
setEverywhereCharacterKey(key);
|
||||
if (key) void searchEverywhereTeams(key);
|
||||
else {
|
||||
everywhereSearchId.current += 1;
|
||||
setEverywhereSearchedKey("");
|
||||
setEverywhereTeams([]);
|
||||
setEverywhereSelectedIds(new Set());
|
||||
setEverywhereError(null);
|
||||
setEverywhereLoading(false);
|
||||
}
|
||||
}}
|
||||
placeholder="เลือกตัวละคร"
|
||||
label="เลือกตัวละครที่อยู่ใน Team"
|
||||
showSelectedImage
|
||||
rarityBackground
|
||||
characterBackground
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Switch id="ignore-same-team-name" checked={ignoreSameName} onCheckedChange={setIgnoreSameName} />
|
||||
<FieldLabel htmlFor="ignore-same-team-name">Ignore same name</FieldLabel>
|
||||
</Field>
|
||||
{everywhereError ? (
|
||||
<Alert variant="destructive"><AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle><AlertDescription>{everywhereError}</AlertDescription></Alert>
|
||||
) : null}
|
||||
{everywhereLoading ? (
|
||||
<div aria-busy="true" aria-label="กำลังโหลด Team" className="grid gap-4 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-72 rounded-xl" />)}
|
||||
</div>
|
||||
) : everywhereSearchedKey && everywhereTeams.length ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
พบ {everywhereTeams.length} Team · เลือกได้ {eligibleEverywhereTeams.length} Team · Condition นี้เหลือที่ว่าง {availableTeamSlots} Team
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!eligibleEverywhereTeams.length}
|
||||
onClick={() => setEverywhereSelectedIds(
|
||||
allEverywhereTeamsSelected ? new Set() : new Set(eligibleEverywhereIds),
|
||||
)}
|
||||
>
|
||||
{allEverywhereTeamsSelected ? "ยกเลิกเลือกทั้งหมด" : "เลือกทั้งหมด"}
|
||||
</Button>
|
||||
</div>
|
||||
{eligibleEverywhereTeams.length > availableTeamSlots ? (
|
||||
<p className="text-sm text-muted-foreground">Import All ใช้ไม่ได้เมื่อจำนวน Team เกินที่ว่าง กรุณาเลือก Team ไม่เกิน {availableTeamSlots} Team</p>
|
||||
) : null}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{everywhereTeams.map((team) => {
|
||||
const eligible = eligibleEverywhereIds.has(team.id);
|
||||
return (
|
||||
<TeamImportPreviewCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
characters={characters}
|
||||
weapons={weapons}
|
||||
artifacts={artifacts}
|
||||
checked={eligible && everywhereSelectedIds.has(team.id)}
|
||||
checkboxId={`everywhere-team-${team.id}`}
|
||||
sourceLabel={`${team.guideName}${team.conditionName ? ` · ${team.conditionName}` : ""}`}
|
||||
disabled={!eligible}
|
||||
onCheckedChange={(checked) => setEverywhereSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(team.id);
|
||||
else next.delete(team.id);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : everywhereSearchedKey && !everywhereError ? (
|
||||
<Empty><EmptyHeader><EmptyTitle>ไม่พบ Team</EmptyTitle><EmptyDescription>ยังไม่มี Team จาก Guide อื่นที่มีตัวละครนี้</EmptyDescription></EmptyHeader></Empty>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="flex-wrap">
|
||||
<Button type="button" variant="outline" onClick={() => setEverywhereImportOpen(false)}>ปิด</Button>
|
||||
<Button type="button" variant="outline" disabled={!eligibleEverywhereTeams.length || eligibleEverywhereTeams.length > availableTeamSlots} onClick={() => importEverywhereTeams(eligibleEverywhereTeams)}>
|
||||
Import All {eligibleEverywhereTeams.length} Team
|
||||
</Button>
|
||||
<Button type="button" disabled={!selectedEverywhereTeams.length || selectedEverywhereTeams.length > availableTeamSlots} onClick={() => importEverywhereTeams(selectedEverywhereTeams)}>
|
||||
Import Selected {selectedEverywhereTeams.length} Team
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={guideImportOpen} onOpenChange={setGuideImportOpen}>
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
@@ -3956,22 +4242,17 @@ function TeamEditor({
|
||||
|
||||
{filteredImportTeams.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filteredImportTeams.map((team) => {
|
||||
const checked = Boolean(selectedImports[team.id]);
|
||||
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
return (
|
||||
<Card key={team.id} className={cn(checked && "ring-2 ring-primary")}>
|
||||
<CardHeader>
|
||||
<CardTitle>{team.title}</CardTitle>
|
||||
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
|
||||
<CardAction>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`guide-team-${team.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => setSelectedImports((current) => {
|
||||
if (nextChecked === true) {
|
||||
{filteredImportTeams.map((team) => (
|
||||
<TeamImportPreviewCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
characters={characters}
|
||||
weapons={weapons}
|
||||
artifacts={artifacts}
|
||||
checked={Boolean(selectedImports[team.id])}
|
||||
checkboxId={`guide-team-${team.id}`}
|
||||
onCheckedChange={(checked) => setSelectedImports((current) => {
|
||||
if (checked) {
|
||||
return { ...current, [team.id]: {
|
||||
title: team.title,
|
||||
rotationSeconds: team.rotationSeconds,
|
||||
@@ -3992,60 +4273,7 @@ function TeamEditor({
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
<FieldLabel htmlFor={`guide-team-${team.id}`}>เลือก</FieldLabel>
|
||||
</Field>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{team.members.map((member) => {
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const weapon = weapons.find((item) => item.key === member.weaponKey);
|
||||
const artifact = artifacts.find((item) => item.key === member.artifactKey);
|
||||
const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey);
|
||||
return (
|
||||
<div key={member.characterKey} className="min-w-0 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", characterRarityGradientClass(member.characterKey, isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
|
||||
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{weapon ? (
|
||||
<div className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(weapon.rarity))}>
|
||||
<Image src={weapon.imageUrl} alt={weapon.name} fill sizes="28px" className="object-contain p-0.5" />
|
||||
</div>
|
||||
) : null}
|
||||
{artifact || secondArtifact ? (
|
||||
<TeamArtifactIcon
|
||||
first={artifact ? { ...artifact, rarity: artifact.rarity ?? null } : null}
|
||||
second={secondArtifact ? { ...secondArtifact, rarity: secondArtifact.rarity ?? null } : null}
|
||||
sizes="28px"
|
||||
className="size-7 shrink-0 scale-100"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-1">
|
||||
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
|
||||
<Badge variant="secondary">{teamRefinementLabel(member.refinement)}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
|
||||
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
|
||||
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
|
||||
</div>
|
||||
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>
|
||||
|
||||
@@ -26,10 +26,10 @@ vi.mock("@/db", () => ({
|
||||
|
||||
import {
|
||||
artifactConditions, artifactProfiles, constellationConditions, extraSectionRows,
|
||||
guides, guideSections, teamConditions, weaponConditions,
|
||||
guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions,
|
||||
catalogMaterials, guidePosterBleeding, media,
|
||||
} from "@/db/schema";
|
||||
import { getAdminGuideContent } from "./queries";
|
||||
import { getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.reads.length = 0;
|
||||
@@ -102,3 +102,32 @@ describe("admin section reads", () => {
|
||||
expect(mocks.reads.filter((table) => table === media)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("team import lookup", () => {
|
||||
it("hydrates matching teams and source labels with one member read", async () => {
|
||||
mocks.rows.set(teamCompositions, [{
|
||||
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
|
||||
guideName: "Other Guide", conditionName: "Boss",
|
||||
}]);
|
||||
mocks.rows.set(teamMembers, [{
|
||||
member: {
|
||||
teamId: "team-1", characterKey: "character-1", weaponKey: "weapon-1",
|
||||
artifactKey: "artifact-1", secondArtifactKey: null,
|
||||
constellation: "2", refinement: "1", damage: "100",
|
||||
},
|
||||
characterName: "Character One",
|
||||
}]);
|
||||
|
||||
const result = await listAdminTeamImportsForCharacter("current-guide", "character-1");
|
||||
expect(result).toEqual([{
|
||||
id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q",
|
||||
guideName: "Other Guide", conditionName: "Boss",
|
||||
members: [{
|
||||
characterKey: "character-1", characterName: "Character One", weaponKey: "weapon-1",
|
||||
artifactKey: "artifact-1", secondArtifactKey: null,
|
||||
constellation: "2", refinement: "1", damage: "100",
|
||||
}],
|
||||
}]);
|
||||
expect(mocks.reads).toEqual([teamCompositions, teamMembers]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,6 +200,64 @@ export async function listAdminTeamImportGuides(excludeGuideId: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listAdminTeamImportsForCharacter(
|
||||
excludeGuideId: string,
|
||||
characterKey: string,
|
||||
) {
|
||||
const matches = await getDb()
|
||||
.select({
|
||||
id: teamCompositions.id,
|
||||
title: teamCompositions.title,
|
||||
rotationSeconds: teamCompositions.rotationSeconds,
|
||||
combo: teamCompositions.combo,
|
||||
guideName: guides.name,
|
||||
conditionName: teamConditions.name,
|
||||
})
|
||||
.from(teamCompositions)
|
||||
.innerJoin(guides, eq(guides.id, teamCompositions.guideId))
|
||||
.innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id))
|
||||
.leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId))
|
||||
.where(and(
|
||||
eq(teamMembers.characterKey, characterKey),
|
||||
ne(guides.id, excludeGuideId),
|
||||
isNull(guides.trashedAt),
|
||||
))
|
||||
.orderBy(asc(guides.name), asc(guides.id), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamCompositions.id));
|
||||
|
||||
if (!matches.length) return [];
|
||||
const members = await getDb()
|
||||
.select({ member: teamMembers, characterName: catalogCharacters.name })
|
||||
.from(teamMembers)
|
||||
.leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey))
|
||||
.where(inArray(teamMembers.teamId, matches.map((team) => team.id)))
|
||||
.orderBy(asc(teamMembers.position));
|
||||
const membersByTeam = new Map<string, Array<{
|
||||
characterKey: string;
|
||||
characterName: string;
|
||||
weaponKey: string | null;
|
||||
artifactKey: string | null;
|
||||
secondArtifactKey: string | null;
|
||||
constellation: string;
|
||||
refinement: string;
|
||||
damage: string;
|
||||
}>>();
|
||||
for (const { member, characterName } of members) {
|
||||
const group = membersByTeam.get(member.teamId) ?? [];
|
||||
group.push({
|
||||
characterKey: member.characterKey,
|
||||
characterName: characterName ?? member.characterKey,
|
||||
weaponKey: member.weaponKey,
|
||||
artifactKey: member.artifactKey,
|
||||
secondArtifactKey: member.secondArtifactKey,
|
||||
constellation: member.constellation,
|
||||
refinement: member.refinement,
|
||||
damage: member.damage,
|
||||
});
|
||||
membersByTeam.set(member.teamId, group);
|
||||
}
|
||||
return matches.map((team) => ({ ...team, members: membersByTeam.get(team.id) ?? [] }));
|
||||
}
|
||||
|
||||
export async function getAdminTeamImportGuide(guideId: string) {
|
||||
const [guide] = await getDb()
|
||||
.select({ id: guides.id, name: guides.name })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyTeamFields,
|
||||
cloneTeamImports,
|
||||
filterTeamImportsByName,
|
||||
findInvalidTeamCatalogReference,
|
||||
findUnsupportedTeamWeapon,
|
||||
limitPresets,
|
||||
@@ -160,6 +161,12 @@ describe("team bulk tools", () => {
|
||||
expect(imported[0].members[0]).not.toBe(source.members[0]);
|
||||
});
|
||||
|
||||
it("ignores existing and repeated names unless duplicate filtering is disabled", () => {
|
||||
const candidates = [team("one", " Alpha "), team("two", "Beta"), team("three", "BETA")];
|
||||
expect(filterTeamImportsByName([team("current", "alpha")], candidates, true).map((item) => item.clientId)).toEqual(["two"]);
|
||||
expect(filterTeamImportsByName([team("current", "alpha")], candidates, false)).toEqual(candidates);
|
||||
});
|
||||
|
||||
it("rejects malformed and unsupported team transfers", () => {
|
||||
expect(() => parseTeamTransfer("not-json")).toThrow();
|
||||
expect(() => parseTeamTransfer(JSON.stringify({
|
||||
|
||||
@@ -125,6 +125,21 @@ export function cloneTeamImports(
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterTeamImportsByName<T extends { title: string }>(
|
||||
currentTeams: readonly { title: string }[],
|
||||
candidates: readonly T[],
|
||||
ignoreSameName: boolean,
|
||||
): T[] {
|
||||
if (!ignoreSameName) return [...candidates];
|
||||
const names = new Set(currentTeams.map((team) => team.title.trim().toLocaleLowerCase()));
|
||||
return candidates.filter((team) => {
|
||||
const name = team.title.trim().toLocaleLowerCase();
|
||||
if (names.has(name)) return false;
|
||||
names.add(name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function findInvalidTeamCatalogReference(
|
||||
team: Omit<TeamToolValue, "clientId">,
|
||||
characters: readonly TeamToolCatalogItem[],
|
||||
|
||||
Reference in New Issue
Block a user