Files
2025-11-11 03:08:36 -03:00

653 lines
22 KiB
TypeScript

"use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Pencil, Plus, RefreshCw } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ItemCreationModal,
type ItemFormPayload,
} from "@/components/admin/items/ItemCreationModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface AdminItemRecord {
item_id: number;
source_table_id: number;
source_table_name: string;
item_name: string;
category: string;
image_local_path: string | null;
image_url: string | null;
rarity: string | null;
level: number | null;
weight: number | null;
slots: number | null;
skill: string | null;
metadata: Record<string, any>;
created_at: string | Date;
updated_at: string | Date;
servers: string[];
tags: string[];
description?: string | null;
}
export interface AdminItemsResponse {
items: AdminItemRecord[];
total: number;
page: number;
pageSize: number;
servers: string[];
categories: string[];
}
interface AdminItemManagerProps {
initialData: AdminItemsResponse;
locale: string;
}
export function AdminItemManager({
initialData,
locale,
}: AdminItemManagerProps) {
const router = useRouter();
const [data, setData] = useState<AdminItemsResponse>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiStatus, setApiStatus] = useState<{
code: number | null;
ok: boolean | null;
durationMs: number | null;
checkedAt: string | null;
}>({ code: null, ok: null, durationMs: null, checkedAt: null });
const [serverFilter, setServerFilter] = useState<string>("all");
const [search, setSearch] = useState<string>("");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [rarityFilter, setRarityFilter] = useState<string>("all");
const [page, setPage] = useState<number>(initialData.page ?? 1);
const pageSize = initialData.pageSize ?? 50;
const [modalOpen, setModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]);
const [_serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined);
function isAdminItemsResponse(val: any): val is AdminItemsResponse {
return (
val &&
Array.isArray(val.items) &&
typeof val.total === "number" &&
typeof val.page === "number" &&
typeof val.pageSize === "number" &&
Array.isArray(val.servers) &&
Array.isArray(val.categories)
);
}
// Carrega servidores ativos independentemente dos itens cadastrados
useEffect(() => {
let cancelled = false;
async function loadServers() {
try {
setServersLoading(true);
const res = await fetch("/api/servers", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
console.warn(
"[AdminItemManager] falha ao buscar servidores ativos:",
res.status,
);
return;
}
const json = await res.json();
const names: string[] = Array.isArray(json?.servers)
? json.servers
.map((s: any) =>
typeof s?.serverName === "string" ? s.serverName : null,
)
.filter((n: string | null): n is string => Boolean(n))
: [];
if (!cancelled) {
setActiveServers(names);
}
} catch (e) {
console.warn("[AdminItemManager] erro ao buscar /api/servers:", e);
} finally {
if (!cancelled) setServersLoading(false);
}
}
loadServers();
return () => {
cancelled = true;
};
}, []);
const fetchItems = useCallback(async () => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
if (serverFilter !== "all") params.set("server", serverFilter);
if (categoryFilter !== "all") params.set("category", categoryFilter);
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
params.set("page", String(page));
params.set("pageSize", String(pageSize));
const url = `/api/admin/items?${params.toString()}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 15000);
const start = performance.now();
console.group("[AdminItemManager] fetchItems");
console.log("URL:", url);
console.log("QueryParams:", Object.fromEntries(params.entries()));
try {
const res = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - start);
setApiStatus({
code: res.status,
ok: res.ok,
durationMs,
checkedAt: new Date().toISOString(),
});
console.log(
"Status:",
res.status,
res.statusText,
"Duration:",
`${durationMs}ms`,
);
console.log("Headers:", {
"content-type": res.headers.get("content-type"),
"x-powered-by": res.headers.get("x-powered-by"),
});
if (!res.ok) {
let errorPayload: any = null;
try {
errorPayload = await res.json();
} catch {
// pode não ser JSON
}
const baseMsg =
res.status === 401
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
: res.status === 403
? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens.";
const apiMsg = errorPayload?.error || errorPayload?.message || null;
const composed = apiMsg
? `${baseMsg} (${apiMsg})`
: `${baseMsg} (HTTP ${res.status})`;
console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed);
}
const json = await res.json();
if (!isAdminItemsResponse(json)) {
console.error("Resposta inválida da API:", json);
throw new Error("Resposta inválida da API de itens.");
}
setData(json);
console.log("Itens carregados:", {
total: json.total,
page: json.page,
pageSize: json.pageSize,
});
} catch (err: any) {
const durationMs = Math.round(performance.now() - start);
setApiStatus((prev) => ({
code: prev.code,
ok: false,
durationMs,
checkedAt: new Date().toISOString(),
}));
if (err?.name === "AbortError") {
console.error("Requisição abortada por timeout");
setError("Tempo de resposta excedido. Tente novamente.");
} else {
console.error("Falha ao carregar itens:", err);
setError(
err instanceof Error ? err.message : "Falha ao carregar itens.",
);
}
} finally {
console.groupEnd();
window.clearTimeout(timeoutId);
setLoading(false);
}
}, [
search,
serverFilter,
categoryFilter,
rarityFilter,
page,
pageSize,
isAdminItemsResponse,
]);
useEffect(() => {
if (searchDebounce.current !== undefined) {
window.clearTimeout(searchDebounce.current);
}
searchDebounce.current = window.setTimeout(() => {
fetchItems();
}, 350);
return () => {
if (searchDebounce.current !== undefined) {
window.clearTimeout(searchDebounce.current);
searchDebounce.current = undefined;
}
};
}, [fetchItems]);
const rarityOptions = useMemo(() => {
const set = new Set<string>();
data.items.forEach((item) => {
if (item.rarity) {
set.add(item.rarity);
}
});
return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
);
}, [data.items]);
const openEditModal = (item: AdminItemRecord) => {
setSelectedItem(item);
setModalOpen(true);
};
const handleSubmit = async (payload: ItemFormPayload) => {
setIsSubmitting(true);
try {
const method = "PUT";
const body: Record<string, unknown> = {
id: payload.id,
name: payload.name,
category: payload.category,
rarity: payload.rarity,
imageUrl: payload.imageUrl,
imageLocalPath: payload.imageLocalPath,
level: payload.level,
weight: payload.weight,
slots: payload.slots,
skill: payload.skill,
description: payload.description,
servers: payload.servers,
tags: payload.tags,
metadata: payload.metadata,
fields: payload.fields,
};
const res = await fetch("/api/admin/items", {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const error = await res
.json()
.catch(() => ({ error: "Erro desconhecido" }));
throw new Error(
error?.error || error?.message || "Falha ao salvar item",
);
}
await fetchItems();
setModalOpen(false);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao salvar item.");
} finally {
setIsSubmitting(false);
}
};
const itemsForDisplay = data.items;
const serverTabs = ["all", ...data.servers];
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<CardTitle>Todos os itens cadastrados</CardTitle>
<CardDescription>
Visualize e edite os registros unificados importados do database
do Tibia Wiki. Itens específicos de servidores podem ser marcados
através das tags.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchItems}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
</Button>
<Button
size="sm"
onClick={() => {
router.push(`/${locale}/admin/items/new`);
}}
>
<Plus className="mr-2 h-4 w-4" /> Novo item
</Button>
</div>
</CardHeader>
</Card>
<Card>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Input
placeholder="Buscar por nome..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as categorias</SelectItem>
{data.categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={rarityFilter}
onValueChange={(value) => {
setRarityFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Raridade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as raridades</SelectItem>
{rarityOptions.map((rarity) => (
<SelectItem key={rarity} value={rarity}>
{rarity}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-sm text-muted-foreground flex items-center gap-2">
{loading ? "Carregando..." : `${data.total} itens encontrados`}
{apiStatus.code !== null && (
<Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null
? ` • ${apiStatus.durationMs}ms`
: ""}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
{error && (
<Card className="border-destructive/40 bg-destructive/10">
<CardContent className="py-3 text-sm text-destructive">
{error}
</CardContent>
</Card>
)}
{loading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-72 w-full" />
</div>
) : (
<Tabs
value={serverFilter}
onValueChange={(value) => {
setServerFilter(value);
setPage(1);
}}
className="space-y-4"
>
<TabsList className="flex-wrap">
{serverTabs.map((server) => (
<TabsTrigger key={server} value={server} className="capitalize">
{server === "all" ? "Todos" : server}
</TabsTrigger>
))}
</TabsList>
{serverTabs.map((server) => (
<TabsContent key={server} value={server} className="space-y-4">
<div className="w-full overflow-x-auto">
<table className="w-full table-auto border-collapse min-w-[960px]">
<thead>
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
<th className="border border-border px-3 py-2 text-left">
Imagem
</th>
<th className="border border-border px-3 py-2 text-left">
Nome
</th>
<th className="border border-border px-3 py-2 text-left">
Categoria
</th>
<th className="border border-border px-3 py-2 text-left">
Raridade
</th>
<th className="border border-border px-3 py-2 text-left">
Origem
</th>
<th className="border border-border px-3 py-2 text-left">
Servidores
</th>
<th className="border border-border px-3 py-2 text-left">
Atualizado em
</th>
<th className="border border-border px-3 py-2 text-left">
Ações
</th>
</tr>
</thead>
<tbody>
{itemsForDisplay.length === 0 ? (
<tr>
<td
colSpan={8}
className="border border-border px-3 py-6 text-center text-sm text-muted-foreground"
>
Nenhum item encontrado com os filtros atuais.
</td>
</tr>
) : (
itemsForDisplay.map((item) => (
<tr
key={item.item_id}
className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors"
>
<td className="border border-border px-3 py-2 align-middle">
{item.image_url ? (
<div className="relative h-12 w-12 overflow-hidden rounded border">
<Image
src={item.image_url}
alt={item.item_name}
fill
className="object-cover"
/>
</div>
) : (
<span className="text-xs text-muted-foreground">
Sem imagem
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top">
<div className="font-medium">{item.item_name}</div>
<div className="text-[11px] text-muted-foreground font-mono">
#{item.item_id}
</div>
</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.category}
</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.rarity ? (
<Badge
variant="outline"
className="uppercase text-[10px]"
>
{item.rarity}
</Badge>
) : (
<span className="text-muted-foreground text-xs">
-
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
<div>{item.source_table_name}</div>
<div>ID origem: {item.source_table_id}</div>
</td>
<td className="border border-border px-3 py-2 align-top">
{item.servers.length > 0 ? (
<div className="flex flex-wrap gap-1">
{item.servers.map((serverTag) => (
<Badge
key={`${item.item_id}-${serverTag}`}
variant="secondary"
className="text-[10px] uppercase"
>
{serverTag}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">
Global
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
{format(
new Date(item.updated_at),
"dd/MM/yyyy HH:mm",
{ locale: ptBR },
)}
</td>
<td className="border border-border px-3 py-2 align-top">
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => openEditModal(item)}
aria-label="Editar item"
>
<Pencil className="h-4 w-4" /> Editar
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Página {page} • Total de{" "}
{Math.max(1, Math.ceil(data.total / pageSize))}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page <= 1 || loading}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPage((prev) =>
prev >= Math.ceil(data.total / pageSize)
? prev
: prev + 1,
)
}
disabled={
page >= Math.ceil(data.total / pageSize) || loading
}
>
Próxima
</Button>
</div>
</div>
</TabsContent>
))}
</Tabs>
)}
<ItemCreationModal
open={modalOpen}
onOpenChange={setModalOpen}
mode="edit"
item={selectedItem ?? undefined}
categories={data.categories}
serverOptions={activeServers.length ? activeServers : data.servers}
onSubmit={handleSubmit}
submitting={isSubmitting}
/>
</div>
);
}