Replaced the dedicated /admin/subscriptions page with a redirect to the central admin panel’s subscriptions tab. Removed session checks, imports, and the AdminSubscriptionDashboard since routing and access are now handled centrally. This streamlines admin UX and keeps access control unified.
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { RefreshCw, Plus, Pencil } from "lucide-react";
|
|
import Image from "next/image";
|
|
import { format } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
import { ItemCreationModal, type ItemFormPayload } from "@/components/admin/items/ItemCreationModal";
|
|
|
|
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;
|
|
}
|
|
|
|
export function AdminItemManager({ initialData }: AdminItemManagerProps) {
|
|
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(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 [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
|
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const searchDebounce = useRef<number | undefined>(undefined);
|
|
|
|
const fetchItems = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
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 res = await fetch(`/api/admin/items?${params.toString()}`, {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error("Não foi possível carregar os itens.");
|
|
}
|
|
const json = (await res.json()) as AdminItemsResponse;
|
|
setData(json);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
|
|
|
|
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 openCreateModal = () => {
|
|
setModalMode("create");
|
|
setSelectedItem(null);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEditModal = (item: AdminItemRecord) => {
|
|
setModalMode("edit");
|
|
setSelectedItem(item);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async (payload: ItemFormPayload) => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
const method = modalMode === "edit" ? "PUT" : "POST";
|
|
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={openCreateModal}>
|
|
<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">
|
|
{loading ? "Carregando..." : `${data.total} itens encontrados`}
|
|
</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={modalMode}
|
|
item={selectedItem ?? undefined}
|
|
categories={data.categories}
|
|
serverOptions={data.servers}
|
|
onSubmit={handleSubmit}
|
|
submitting={isSubmitting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|