feat(admin): centralize subscriptions under /admin?tab=subscriptions
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.
This commit is contained in:
1 parent
d8e7d5d7e6
commit
62ab5b2608
19 files changed
+3091
-357
No files matched your search
@@ -57,7 +57,7 @@ export default function Header() {
|
||||
const navLinks = [
|
||||
...links,
|
||||
...(isAuthenticated && user?.userId === 1
|
||||
? [{ href: `/${locale}/admin/subscriptions`, label: "Admin" }]
|
||||
? [{ href: `/${locale}/admin`, label: "Admin" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
|
||||
import { AdminItemManager, type AdminItemsResponse } from "@/components/admin/items/AdminItemManager";
|
||||
|
||||
interface AdminPanelTabsProps {
|
||||
adminUser: {
|
||||
subscription_tier?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
username?: string;
|
||||
email?: string;
|
||||
} | null;
|
||||
initialTiers: any[];
|
||||
itemsData: AdminItemsResponse;
|
||||
initialTab?: "overview" | "subscriptions" | "items";
|
||||
}
|
||||
|
||||
export function AdminPanelTabs({
|
||||
adminUser,
|
||||
initialTiers,
|
||||
itemsData,
|
||||
initialTab = "overview",
|
||||
}: AdminPanelTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
||||
initialTab
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as typeof activeTab)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions">Assinaturas & Permissões</TabsTrigger>
|
||||
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Utilize as abas acima para administrar planos de assinatura, permissões
|
||||
e a curadoria de itens do marketplace. Esta área é exclusiva para
|
||||
operadores autorizados.
|
||||
</p>
|
||||
<p>
|
||||
Para solicitações críticas, lembre-se de registrar mudanças e validar
|
||||
com a equipe responsável antes de disponibilizar aos usuários finais.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions">
|
||||
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items">
|
||||
<AdminItemManager initialData={itemsData} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface CategoryColumn {
|
||||
name: string;
|
||||
type: "TEXT" | "INTEGER" | "REAL" | "BOOLEAN";
|
||||
notNull?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryTemplate {
|
||||
name: string;
|
||||
columns: CategoryColumn[];
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
const BASE_COLUMN_NAMES = ["id", "nome", "image_url", "image_local_path"];
|
||||
|
||||
function generateTempId() {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
export interface ItemFormPayload {
|
||||
id?: number;
|
||||
name: string;
|
||||
category: string;
|
||||
rarity?: string | null;
|
||||
imageUrl?: string | null;
|
||||
imageLocalPath?: string | null;
|
||||
level?: number | null;
|
||||
weight?: number | null;
|
||||
slots?: number | null;
|
||||
skill?: string | null;
|
||||
description?: string | null;
|
||||
servers: string[];
|
||||
tags: string[];
|
||||
metadata?: Record<string, any>;
|
||||
fields?: Record<string, string | boolean | null>;
|
||||
}
|
||||
|
||||
interface ItemCreationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "create" | "edit";
|
||||
item?: {
|
||||
item_id: number;
|
||||
item_name: string;
|
||||
category: string;
|
||||
rarity: string | null;
|
||||
image_url: string | null;
|
||||
image_local_path: string | null;
|
||||
level: number | null;
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
description?: string | null;
|
||||
servers: string[];
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
categories: string[];
|
||||
serverOptions: string[];
|
||||
onSubmit: (payload: ItemFormPayload) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
type AnalysisResult = {
|
||||
table: string;
|
||||
columns: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
function parseCsv(input: string): string[] {
|
||||
return input
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function serializeCsv(list: string[]): string {
|
||||
if (!Array.isArray(list) || list.length === 0) return "";
|
||||
return list.join(", ");
|
||||
}
|
||||
|
||||
function parseNumberInput(value: string): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function ItemCreationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
item,
|
||||
categories,
|
||||
serverOptions,
|
||||
onSubmit,
|
||||
submitting,
|
||||
}: ItemCreationModalProps) {
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [rarity, setRarity] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [imageLocalPath, setImageLocalPath] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [level, setLevel] = useState("");
|
||||
const [weight, setWeight] = useState("");
|
||||
const [slots, setSlots] = useState("");
|
||||
const [skill, setSkill] = useState("");
|
||||
const [serversCsv, setServersCsv] = useState("");
|
||||
const [tagsCsv, setTagsCsv] = useState("");
|
||||
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult>(null);
|
||||
const [analysisLoading, setAnalysisLoading] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
|
||||
const allowAiTools = !isEdit;
|
||||
|
||||
const [categoryTemplates, setCategoryTemplates] = useState<CategoryTemplate[]>([]);
|
||||
const [categoryLoading, setCategoryLoading] = useState(false);
|
||||
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||
const [dynamicValues, setDynamicValues] = useState<Record<string, string | boolean | null>>({});
|
||||
const [showNewCategoryModal, setShowNewCategoryModal] = useState(false);
|
||||
const [showNewFieldModal, setShowNewFieldModal] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [newCategoryFields, setNewCategoryFields] = useState<Array<{ id: string; name: string; type: CategoryColumn["type"]; notNull: boolean }>>([]);
|
||||
const [newCategoryError, setNewCategoryError] = useState<string | null>(null);
|
||||
const [newFieldName, setNewFieldName] = useState("");
|
||||
const [newFieldType, setNewFieldType] = useState<CategoryColumn["type"]>("TEXT");
|
||||
const [newFieldNotNull, setNewFieldNotNull] = useState(false);
|
||||
const [newFieldError, setNewFieldError] = useState<string | null>(null);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setCategoryLoading(true);
|
||||
setCategoryError(null);
|
||||
try {
|
||||
const response = await fetch("/api/admin/categories", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Falha ao carregar categorias");
|
||||
}
|
||||
const data = await response.json();
|
||||
setCategoryTemplates(Array.isArray(data?.categories) ? data.categories : []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCategoryError(error instanceof Error ? error.message : "Erro ao carregar categorias");
|
||||
} finally {
|
||||
setCategoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dynamicInitializedRef = useRef(false);
|
||||
const lastCategoryRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchCategories();
|
||||
}, [open, fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSubmitStatus(null);
|
||||
setAnalysis(null);
|
||||
setAnalysisError(null);
|
||||
dynamicInitializedRef.current = false;
|
||||
lastCategoryRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit && item) {
|
||||
setName(item.item_name);
|
||||
setCategory(item.category.toLowerCase());
|
||||
setRarity(item.rarity ?? "");
|
||||
setImageUrl(item.image_url ?? "");
|
||||
setImageLocalPath(item.image_local_path ?? "");
|
||||
setDescription(item.description ?? "");
|
||||
setLevel(item.level !== null && item.level !== undefined ? String(item.level) : "");
|
||||
setWeight(item.weight !== null && item.weight !== undefined ? String(item.weight) : "");
|
||||
setSlots(item.slots !== null && item.slots !== undefined ? String(item.slots) : "");
|
||||
setSkill(item.skill ?? "");
|
||||
setServersCsv(serializeCsv(item.servers));
|
||||
setTagsCsv(serializeCsv(item.tags));
|
||||
setSubmitStatus(null);
|
||||
} else {
|
||||
const defaultCategory = categoryTemplates[0]?.name ?? categories[0] ?? "";
|
||||
setName("");
|
||||
setCategory(defaultCategory.toLowerCase());
|
||||
setRarity("");
|
||||
setImageUrl("");
|
||||
setImageLocalPath("");
|
||||
setDescription("");
|
||||
setLevel("");
|
||||
setWeight("");
|
||||
setSlots("");
|
||||
setSkill("");
|
||||
setServersCsv("");
|
||||
setTagsCsv("");
|
||||
setSubmitStatus(null);
|
||||
}
|
||||
}, [open, isEdit, item, categories, categoryTemplates]);
|
||||
|
||||
const getTemplateForCategory = useCallback(
|
||||
(categoryName: string | null | undefined) =>
|
||||
categoryTemplates.find((template) => template.name === (categoryName ?? "").toLowerCase()),
|
||||
[categoryTemplates]
|
||||
);
|
||||
|
||||
const buildDynamicInitialValues = useCallback(
|
||||
(template: CategoryTemplate | undefined, metadata?: Record<string, unknown>) => {
|
||||
if (!template) return {} as Record<string, string | boolean | null>;
|
||||
const values: Record<string, string | boolean | null> = {};
|
||||
template.columns
|
||||
.filter((column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false)
|
||||
.forEach((column) => {
|
||||
const sourceValue = metadata ? metadata[column.name] : undefined;
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
values[column.name] = column.type === "BOOLEAN" ? false : "";
|
||||
return;
|
||||
}
|
||||
if (column.type === "BOOLEAN") {
|
||||
values[column.name] = Boolean(Number(sourceValue));
|
||||
} else if (column.type === "INTEGER" || column.type === "REAL") {
|
||||
values[column.name] = String(sourceValue);
|
||||
} else {
|
||||
values[column.name] = String(sourceValue);
|
||||
}
|
||||
});
|
||||
return values;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!allowAiTools) return;
|
||||
if (!name.trim() || !description.trim()) {
|
||||
setAnalysisError("Informe nome e descrição para gerar o JSON.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalysisError(null);
|
||||
setAnalysis(null);
|
||||
setAnalysisLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/items/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
scope: serverOptions.length > 0 ? "server" : "global",
|
||||
server: serverOptions[0] ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Falha ao comunicar com o serviço de IA.");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setAnalysis(data.structure ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setAnalysisError(
|
||||
error instanceof Error ? error.message : "Erro desconhecido ao gerar JSON."
|
||||
);
|
||||
} finally {
|
||||
setAnalysisLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
setSubmitStatus("Informe o nome do item.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category.trim()) {
|
||||
setSubmitStatus("Informe a categoria do item.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitStatus(null);
|
||||
try {
|
||||
const metadataPayload: Record<string, unknown> = {};
|
||||
if (allowAiTools && analysis) {
|
||||
metadataPayload.aiStructure = analysis;
|
||||
}
|
||||
for (const [key, value] of Object.entries(dynamicValues)) {
|
||||
metadataPayload[key] = value;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
id: item?.item_id,
|
||||
name: name.trim(),
|
||||
category: category.trim().toLowerCase(),
|
||||
rarity: rarity.trim() || null,
|
||||
imageUrl: imageUrl.trim() || null,
|
||||
imageLocalPath: imageLocalPath.trim() || null,
|
||||
description: description.trim() || null,
|
||||
level: parseNumberInput(level),
|
||||
weight: parseNumberInput(weight),
|
||||
slots: parseNumberInput(slots),
|
||||
skill: skill.trim() || null,
|
||||
servers: parseCsv(serversCsv),
|
||||
tags: parseCsv(tagsCsv),
|
||||
metadata: metadataPayload,
|
||||
fields: dynamicValues,
|
||||
});
|
||||
|
||||
setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso.");
|
||||
|
||||
if (!isEdit) {
|
||||
setName("");
|
||||
setCategory(categories[0] ?? "");
|
||||
setRarity("");
|
||||
setImageUrl("");
|
||||
setImageLocalPath("");
|
||||
setDescription("");
|
||||
setLevel("");
|
||||
setWeight("");
|
||||
setSlots("");
|
||||
setSkill("");
|
||||
setServersCsv("");
|
||||
setTagsCsv("");
|
||||
setAnalysis(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setSubmitStatus(
|
||||
error instanceof Error ? error.message : "Erro ao salvar. Tente novamente."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDynamicValueChange = useCallback((column: CategoryColumn, value: string | boolean | null) => {
|
||||
setDynamicValues((prev) => ({
|
||||
...prev,
|
||||
[column.name]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleCategoryChange = useCallback((value: string) => {
|
||||
dynamicInitializedRef.current = false;
|
||||
lastCategoryRef.current = null;
|
||||
setCategory(value.toLowerCase());
|
||||
}, []);
|
||||
|
||||
const handleAddCategoryField = useCallback(() => {
|
||||
setNewCategoryFields((prev) => [
|
||||
...prev,
|
||||
{ id: generateTempId(), name: "", type: "TEXT", notNull: false },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const handleUpdateCategoryField = useCallback((id: string, key: "name" | "type" | "notNull", value: string | boolean) => {
|
||||
setNewCategoryFields((prev) =>
|
||||
prev.map((field) =>
|
||||
field.id === id
|
||||
? {
|
||||
...field,
|
||||
[key]: key === "notNull" ? Boolean(value) : value,
|
||||
}
|
||||
: field
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRemoveCategoryField = useCallback((id: string) => {
|
||||
setNewCategoryFields((prev) => prev.filter((field) => field.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleCreateCategory = useCallback(async () => {
|
||||
if (!newCategoryName.trim()) {
|
||||
setNewCategoryError("Informe o nome da categoria");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNewCategoryError(null);
|
||||
const payload = {
|
||||
name: newCategoryName.trim().toLowerCase(),
|
||||
columns: newCategoryFields
|
||||
.filter((field) => field.name.trim())
|
||||
.map((field) => ({
|
||||
name: field.name.trim().toLowerCase(),
|
||||
type: field.type,
|
||||
notNull: field.notNull,
|
||||
})),
|
||||
};
|
||||
|
||||
const res = await fetch("/api/admin/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => null);
|
||||
throw new Error(error?.error || "Falha ao criar categoria");
|
||||
}
|
||||
|
||||
await fetchCategories();
|
||||
handleCategoryChange(payload.name);
|
||||
setShowNewCategoryModal(false);
|
||||
setNewCategoryFields([]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNewCategoryError(error instanceof Error ? error.message : "Erro ao criar categoria");
|
||||
}
|
||||
}, [newCategoryName, newCategoryFields, fetchCategories, handleCategoryChange]);
|
||||
|
||||
const handleAddFieldToCategory = useCallback(async () => {
|
||||
if (!category) {
|
||||
setNewFieldError("Selecione uma categoria");
|
||||
return;
|
||||
}
|
||||
if (!newFieldName.trim()) {
|
||||
setNewFieldError("Informe o nome do campo");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNewFieldError(null);
|
||||
const payload = {
|
||||
name: newFieldName.trim().toLowerCase(),
|
||||
type: newFieldType,
|
||||
notNull: newFieldNotNull,
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/admin/categories/${category}/fields`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => null);
|
||||
throw new Error(error?.error || "Falha ao adicionar campo");
|
||||
}
|
||||
|
||||
await fetchCategories();
|
||||
dynamicInitializedRef.current = false;
|
||||
lastCategoryRef.current = null;
|
||||
setShowNewFieldModal(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNewFieldError(error instanceof Error ? error.message : "Erro ao adicionar campo");
|
||||
}
|
||||
}, [category, newFieldName, newFieldType, newFieldNotNull, fetchCategories]);
|
||||
|
||||
const serverSuggestions = useMemo(() => {
|
||||
if (serverOptions.length === 0) return "";
|
||||
return serverOptions.join(", ");
|
||||
}, [serverOptions]);
|
||||
|
||||
const activeTemplate = useMemo(() => getTemplateForCategory(category), [category, getTemplateForCategory]);
|
||||
|
||||
const dynamicColumns = useMemo(
|
||||
() =>
|
||||
activeTemplate?.columns.filter(
|
||||
(column) => !BASE_COLUMN_NAMES.includes(column.name) && column.editable !== false,
|
||||
) ?? [],
|
||||
[activeTemplate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const effectiveCategory = (category || item?.category || categoryTemplates[0]?.name || "").toLowerCase();
|
||||
if (!effectiveCategory) return;
|
||||
|
||||
const template = getTemplateForCategory(effectiveCategory);
|
||||
if (!template) return;
|
||||
|
||||
if (!category) {
|
||||
setCategory(template.name);
|
||||
}
|
||||
|
||||
const metadataSource = isEdit ? (item?.metadata as Record<string, unknown> | undefined) : undefined;
|
||||
|
||||
if (!dynamicInitializedRef.current || lastCategoryRef.current !== template.name) {
|
||||
setDynamicValues(buildDynamicInitialValues(template, metadataSource));
|
||||
dynamicInitializedRef.current = true;
|
||||
lastCategoryRef.current = template.name;
|
||||
}
|
||||
}, [open, category, categoryTemplates, getTemplateForCategory, buildDynamicInitialValues, isEdit, item]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl w-full h-[90vh] overflow-hidden">
|
||||
<div className="flex h-full flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{isEdit ? "Editar item" : "Novo item"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-2">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.15fr,0.85fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-name">Nome</Label>
|
||||
<Input
|
||||
id="item-name"
|
||||
placeholder="Ex.: Final Fate"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Categoria</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={handleCategoryChange}
|
||||
disabled={categoryLoading || isEdit}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px]">
|
||||
<SelectValue
|
||||
placeholder={categoryLoading ? "Carregando..." : "Selecione a categoria"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryTemplates.map((template) => (
|
||||
<SelectItem key={template.name} value={template.name}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowNewCategoryModal(true);
|
||||
setNewCategoryError(null);
|
||||
setNewCategoryName("");
|
||||
setNewCategoryFields([]);
|
||||
}}
|
||||
disabled={categoryLoading}
|
||||
>
|
||||
Nova categoria
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowNewFieldModal(true);
|
||||
setNewFieldError(null);
|
||||
setNewFieldName("");
|
||||
setNewFieldType("TEXT");
|
||||
setNewFieldNotNull(false);
|
||||
}}
|
||||
disabled={!category || categoryLoading}
|
||||
>
|
||||
+ campo
|
||||
</Button>
|
||||
</div>
|
||||
{categoryError && (
|
||||
<p className="text-xs text-destructive">{categoryError}</p>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A categoria existente do item não pode ser alterada neste fluxo.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-rarity">Raridade</Label>
|
||||
<Input
|
||||
id="item-rarity"
|
||||
placeholder="Ex.: legendary"
|
||||
value={rarity}
|
||||
onChange={(event) => setRarity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-image-url">Imagem (URL)</Label>
|
||||
<Input
|
||||
id="item-image-url"
|
||||
placeholder="https://..."
|
||||
value={imageUrl}
|
||||
onChange={(event) => setImageUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-image-local">Imagem local (caminho)</Label>
|
||||
<Input
|
||||
id="item-image-local"
|
||||
placeholder="Opcional"
|
||||
value={imageLocalPath}
|
||||
onChange={(event) => setImageLocalPath(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-level">Level requerido</Label>
|
||||
<Input
|
||||
id="item-level"
|
||||
placeholder="Ex.: 250"
|
||||
value={level}
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-weight">Peso</Label>
|
||||
<Input
|
||||
id="item-weight"
|
||||
placeholder="Ex.: 45"
|
||||
value={weight}
|
||||
onChange={(event) => setWeight(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-slots">Slots</Label>
|
||||
<Input
|
||||
id="item-slots"
|
||||
placeholder="Ex.: 2"
|
||||
value={slots}
|
||||
onChange={(event) => setSlots(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-skill">Skill requerido</Label>
|
||||
<Input
|
||||
id="item-skill"
|
||||
placeholder="Ex.: Sword 115"
|
||||
value={skill}
|
||||
onChange={(event) => setSkill(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dynamicColumns.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Campos específicos da categoria</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defina valores adicionais conforme o modelo da categoria selecionada.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{dynamicColumns.map((column) => {
|
||||
const value = dynamicValues[column.name];
|
||||
if (column.type === "BOOLEAN") {
|
||||
return (
|
||||
<div key={column.name} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`dynamic-${column.name}`}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDynamicValueChange(column, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputType = column.type === "TEXT" ? "text" : "number";
|
||||
const step = column.type === "REAL" ? "0.01" : "1";
|
||||
|
||||
return (
|
||||
<div key={column.name} className="space-y-2">
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`dynamic-${column.name}`}
|
||||
type={inputType}
|
||||
step={column.type === "TEXT" ? undefined : step}
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? ""
|
||||
: String(value)
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleDynamicValueChange(column, event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-servers">Servidores (separados por vírgula)</Label>
|
||||
<Input
|
||||
id="item-servers"
|
||||
placeholder="Ex.: Ferobra, Boneka"
|
||||
value={serversCsv}
|
||||
onChange={(event) => setServersCsv(event.target.value)}
|
||||
/>
|
||||
{serverSuggestions && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Sugestões cadastradas: {serverSuggestions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-tags">Tags adicionais</Label>
|
||||
<Input
|
||||
id="item-tags"
|
||||
placeholder="Ex.: endgame, two-handed"
|
||||
value={tagsCsv}
|
||||
onChange={(event) => setTagsCsv(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-description">Descrição detalhada</Label>
|
||||
<Textarea
|
||||
id="item-description"
|
||||
placeholder="Cole aqui a descrição oficial do item..."
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{allowAiTools && (
|
||||
<Button type="button" variant="outline" onClick={handleAnalyze} disabled={analysisLoading}>
|
||||
{analysisLoading ? "Analisando..." : "Analisar com IA"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar item"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{submitStatus && <p className="text-xs text-muted-foreground">{submitStatus}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{allowAiTools && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Resultado da IA</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O serviço Gemini deve retornar exclusivamente um JSON válido que descreve a tabela destino e os campos correspondentes.
|
||||
</p>
|
||||
{analysisError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{analysisError}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px]">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{analysis ? JSON.stringify(analysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Resumo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{category || "Categoria"}
|
||||
</Badge>
|
||||
{rarity && (
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{rarity}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
Utilize os campos para definir servidores específicos. Todos os itens sem servidores marcados serão considerados globais.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 pt-4">
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showNewCategoryModal} onOpenChange={setShowNewCategoryModal}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova categoria</DialogTitle>
|
||||
<DialogDescription>
|
||||
Defina nome e campos adicionais para criar uma nova categoria de itens.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-category-name">Nome da categoria</Label>
|
||||
<Input
|
||||
id="new-category-name"
|
||||
placeholder="Ex.: vips"
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Campos iniciais (opcional)</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddCategoryField}>
|
||||
Adicionar campo
|
||||
</Button>
|
||||
</div>
|
||||
{newCategoryFields.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nenhum campo adicional configurado.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{newCategoryFields.map((field) => (
|
||||
<div key={field.id} className="grid gap-2 rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`field-name-${field.id}`}>Nome</Label>
|
||||
<Input
|
||||
id={`field-name-${field.id}`}
|
||||
value={field.name}
|
||||
onChange={(event) =>
|
||||
handleUpdateCategoryField(field.id, "name", event.target.value)
|
||||
}
|
||||
placeholder="Ex.: bonus_magico"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Tipo</Label>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) => handleUpdateCategoryField(field.id, "type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TEXT">Texto</SelectItem>
|
||||
<SelectItem value="INTEGER">Inteiro</SelectItem>
|
||||
<SelectItem value="REAL">Decimal</SelectItem>
|
||||
<SelectItem value="BOOLEAN">Booleano</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`field-required-${field.id}`}
|
||||
checked={field.notNull}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateCategoryField(field.id, "notNull", checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`field-required-${field.id}`}>Obrigatório</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveCategoryField(field.id)}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{newCategoryError && <p className="text-xs text-destructive">{newCategoryError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewCategoryModal(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateCategory}>Criar categoria</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showNewFieldModal} onOpenChange={setShowNewFieldModal}>
|
||||
<DialogContent className="max-w-md max-h-[70vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo campo para categoria</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adicione um novo campo à categoria selecionada. A alteração é permanente no schema.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Categoria</Label>
|
||||
<Input value={category} readOnly className="bg-muted/40" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-field-name">Nome do campo</Label>
|
||||
<Input
|
||||
id="new-field-name"
|
||||
value={newFieldName}
|
||||
onChange={(event) => setNewFieldName(event.target.value)}
|
||||
placeholder="Ex.: bonus_magico"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Tipo</Label>
|
||||
<Select value={newFieldType} onValueChange={(value) => setNewFieldType(value as CategoryColumn["type"])}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TEXT">Texto</SelectItem>
|
||||
<SelectItem value="INTEGER">Inteiro</SelectItem>
|
||||
<SelectItem value="REAL">Decimal</SelectItem>
|
||||
<SelectItem value="BOOLEAN">Booleano</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="new-field-required"
|
||||
checked={newFieldNotNull}
|
||||
onCheckedChange={(checked) => setNewFieldNotNull(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="new-field-required">Obrigatório</Label>
|
||||
</div>
|
||||
</div>
|
||||
{newFieldError && <p className="text-xs text-destructive">{newFieldError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewFieldModal(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleAddFieldToCategory}>Adicionar campo</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user