653 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|