From 62ab5b26084da4b649ed101f55be1628aafbbf86 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 9 Nov 2025 01:50:50 -0300 Subject: [PATCH] feat(admin): centralize subscriptions under /admin?tab=subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/[locale]/admin/page.tsx | 94 ++ app/[locale]/admin/subscriptions/page.tsx | 50 +- .../admin/categories/[name]/fields/route.ts | 50 + app/api/admin/categories/route.ts | 83 ++ app/api/admin/items/analyze/route.ts | 46 + app/api/admin/items/route.ts | 297 ++++++ app/api/items/categories/route.ts | 32 +- app/api/items/route.ts | 54 +- components/Header.tsx | 2 +- components/admin/AdminPanelTabs.tsx | 74 ++ components/admin/items/AdminItemManager.tsx | 407 +++++++ components/admin/items/ItemCreationModal.tsx | 990 ++++++++++++++++++ lib/ai/generate-item-structure.ts | 146 +++ lib/items/import-from-sqlite.ts | 267 +++++ lib/items/sqlite-manager.ts | 277 +++++ lib/prisma/queries/items.ts | 362 +++++-- package.json | 1 + pnpm-lock.yaml | 8 + scripts/populate-items-table.ts | 208 +--- 19 files changed, 3091 insertions(+), 357 deletions(-) create mode 100644 app/[locale]/admin/page.tsx create mode 100644 app/api/admin/categories/[name]/fields/route.ts create mode 100644 app/api/admin/categories/route.ts create mode 100644 app/api/admin/items/analyze/route.ts create mode 100644 app/api/admin/items/route.ts create mode 100644 components/admin/AdminPanelTabs.tsx create mode 100644 components/admin/items/AdminItemManager.tsx create mode 100644 components/admin/items/ItemCreationModal.tsx create mode 100644 lib/ai/generate-item-structure.ts create mode 100644 lib/items/import-from-sqlite.ts create mode 100644 lib/items/sqlite-manager.ts diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..14013c4 --- /dev/null +++ b/app/[locale]/admin/page.tsx @@ -0,0 +1,94 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Header from "@/components/Header"; +import { getSession } from "@/lib/redis/session"; +import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { AdminPanelTabs } from "@/components/admin/AdminPanelTabs"; +import { searchAdminItems } from "@/lib/prisma/queries/items"; + +export async function generateMetadata(): Promise { + return { + title: "Painel Administrativo", + description: + "Gerencie assinaturas, permissões e catalogação de itens exclusivos do Tibia Trade.", + }; +} + +export default async function AdminPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: + | Promise> + | Record; +}) { + const { locale } = await params; + const resolvedSearchParams = + searchParams instanceof Promise ? await searchParams : searchParams; + const tabParam = resolvedSearchParams?.tab; + const initialTab = + typeof tabParam === "string" && ["overview", "subscriptions", "items"].includes(tabParam) + ? (tabParam as "overview" | "subscriptions" | "items") + : "overview"; + + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (!sessionId) { + redirect(`/${locale}`); + } + + const session = await getSession(sessionId); + if (!session || session.userId !== 1) { + redirect(`/${locale}`); + } + + const [tiers, adminUser, initialItems] = await Promise.all([ + getAllSubscriptionTiers(true), + getUserById(session.userId), + searchAdminItems({ page: 1, pageSize: 50 }), + ]); + + const normalizedTiers = tiers.map((tier) => { + const featureList = (tier.features ?? []) as Array>; + return { + ...tier, + price_monthly: tier.price_monthly ? Number(tier.price_monthly) : null, + price_yearly: tier.price_yearly ? Number(tier.price_yearly) : null, + created_at: tier.created_at instanceof Date ? tier.created_at.toISOString() : tier.created_at, + updated_at: tier.updated_at instanceof Date ? tier.updated_at.toISOString() : tier.updated_at, + features: featureList.map((feature) => ({ + ...feature, + created_at: + feature.created_at instanceof Date ? feature.created_at.toISOString() : feature.created_at ?? null, + updated_at: + feature.updated_at instanceof Date ? feature.updated_at.toISOString() : feature.updated_at ?? null, + })), + }; + }); + + return ( +
+
+
+
+

Painel administrativo

+

+ Centralize a gestão de planos, permissões e o catálogo de itens. Utilize as abas para acessar cada área temática. +

+
+ + +
+
+ ); +} + diff --git a/app/[locale]/admin/subscriptions/page.tsx b/app/[locale]/admin/subscriptions/page.tsx index 1a3ee98..4ddf09a 100644 --- a/app/[locale]/admin/subscriptions/page.tsx +++ b/app/[locale]/admin/subscriptions/page.tsx @@ -1,64 +1,20 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { cookies } from "next/headers"; -import Header from "@/components/Header"; -import { getSession } from "@/lib/redis/session"; -import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers"; -import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard"; -import { getUserById } from "@/lib/prisma/queries/users"; export async function generateMetadata(): Promise { return { title: "Assinaturas e Permissões", description: - "Gerencie os planos de assinatura, recursos avançados e permissões dos usuários.", + "Redirecionando para o painel administrativo centralizado.", }; } -export default async function AdminSubscriptionsPage({ +export default async function AdminSubscriptionsRedirect({ params, }: { params: Promise<{ locale: string }>; }) { const { locale } = await params; - const cookieStore = await cookies(); - const sessionId = cookieStore.get("sessionId")?.value; - - if (!sessionId) { - redirect(`/${locale}`); - } - - const session = await getSession(sessionId); - - if (!session || session.userId !== 1) { - redirect(`/${locale}`); - } - - const tiers = await getAllSubscriptionTiers(true); - const adminUser = await getUserById(session.userId); - - return ( -
-
-
-
-

Assinaturas & Permissões

-

- Configure planos, recursos e vincule usuários às assinaturas disponíveis. -

- {adminUser?.subscription_tier && ( -

- Seu plano atual:{" "} - - {adminUser.subscription_tier.name} ({adminUser.subscription_tier.slug}) - -

- )} -
- - -
-
- ); + redirect(`/${locale}/admin?tab=subscriptions`); } diff --git a/app/api/admin/categories/[name]/fields/route.ts b/app/api/admin/categories/[name]/fields/route.ts new file mode 100644 index 0000000..b1b2348 --- /dev/null +++ b/app/api/admin/categories/[name]/fields/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { addColumnToCategory, type SQLiteColumnDefinition } from "@/lib/items/sqlite-manager"; +import { secureLogger } from "@/lib/secure-logger"; + +function sanitizeColumn(column: any): SQLiteColumnDefinition { + const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : ""; + const type = typeof column?.type === "string" ? column.type : "TEXT"; + const notNull = column?.notNull === true; + const defaultValue = column?.defaultValue ?? undefined; + + if (!name) { + throw new Error("Nome da coluna é obrigatório"); + } + + if (["id", "nome", "image_url", "image_local_path"].includes(name)) { + throw new Error("Coluna reservada não pode ser criada"); + } + + return { + name, + type: type.toUpperCase(), + notNull, + defaultValue, + editable: column?.editable !== false, + } satisfies SQLiteColumnDefinition; +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + const { name } = await params; + + try { + const body = await request.json(); + const column = sanitizeColumn(body?.column ?? body); + const created = addColumnToCategory({ category: name, column }); + return NextResponse.json({ success: true, column: created }, { status: 201 }); + } catch (error) { + secureLogger.error("Falha ao adicionar coluna à categoria", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Erro ao adicionar coluna" }, + { status: 400 }, + ); + } +} + diff --git a/app/api/admin/categories/route.ts b/app/api/admin/categories/route.ts new file mode 100644 index 0000000..c470b1a --- /dev/null +++ b/app/api/admin/categories/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { + listCategoryTemplates, + createCategoryTemplate, + type SQLiteColumnDefinition, + type CreateCategoryInput, +} from "@/lib/items/sqlite-manager"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function GET(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const templates = listCategoryTemplates(); + return NextResponse.json({ success: true, categories: templates }, { status: 200 }); + } catch (error) { + secureLogger.error("Falha ao listar categorias dinâmicas", error); + return NextResponse.json( + { success: false, error: "Não foi possível listar categorias" }, + { status: 500 }, + ); + } +} + +function sanitizeColumn(column: any): SQLiteColumnDefinition { + const name = typeof column?.name === "string" ? column.name.trim().toLowerCase() : ""; + const type = typeof column?.type === "string" ? column.type : "TEXT"; + const notNull = column?.notNull === true; + const defaultValue = column?.defaultValue ?? undefined; + + if (!name) { + throw new Error("Nome da coluna é obrigatório"); + } + + return { + name, + type: type.toUpperCase(), + notNull, + defaultValue, + editable: column?.editable !== false, + } satisfies SQLiteColumnDefinition; +} + +export async function POST(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const rawName = typeof body?.name === "string" ? body.name.trim().toLowerCase() : ""; + if (!rawName) { + return NextResponse.json( + { success: false, error: "Nome da categoria é obrigatório" }, + { status: 400 }, + ); + } + + const columnsArray: unknown[] = Array.isArray(body?.columns) ? body.columns : []; + const columns = columnsArray.map((column) => sanitizeColumn(column)).filter((column) => { + return !(column && ["id", "nome", "image_url", "image_local_path"].includes(column.name)); + }); + + const input: CreateCategoryInput = { + name: rawName, + columns, + }; + + const category = createCategoryTemplate(input); + return NextResponse.json({ success: true, category }, { status: 201 }); + } catch (error) { + secureLogger.error("Falha ao criar categoria dinâmica", error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : "Erro ao criar categoria" }, + { status: 400 }, + ); + } +} diff --git a/app/api/admin/items/analyze/route.ts b/app/api/admin/items/analyze/route.ts new file mode 100644 index 0000000..3f40a5e --- /dev/null +++ b/app/api/admin/items/analyze/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { generateItemStructure } from "@/lib/ai/generate-item-structure"; + +export async function POST(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + const body = await request.json(); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const description = + typeof body?.description === "string" ? body.description.trim() : ""; + const scope = body?.scope === "server" ? "server" : "global"; + const server = + scope === "server" && typeof body?.server === "string" + ? body.server.trim() + : null; + + if (!name || !description) { + return NextResponse.json( + { + success: false, + error: "Informe nome e descrição para gerar o JSON estrutural.", + }, + { status: 400 } + ); + } + + const result = await generateItemStructure({ + name, + description, + scope, + server, + }); + + return NextResponse.json( + { + success: true, + structure: result.structure, + }, + { status: 200 } + ); +} + diff --git a/app/api/admin/items/route.ts b/app/api/admin/items/route.ts new file mode 100644 index 0000000..313dc02 --- /dev/null +++ b/app/api/admin/items/route.ts @@ -0,0 +1,297 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { + createAdminItem, + searchAdminItems, + updateAdminItem, + getItemById, +} from "@/lib/prisma/queries/items"; +import { + insertCategoryItem, + updateCategoryItem, + listCategoryTemplates, +} from "@/lib/items/sqlite-manager"; + +function parseNumber(value: string | null | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeDynamicFields(category: string, rawFields: Record | undefined) { + if (!rawFields || typeof rawFields !== "object") { + return {} as Record; + } + + const templates = listCategoryTemplates(); + const template = templates.find((entry) => entry.name === category.toLowerCase()); + if (!template) { + return rawFields; + } + + const columnMap = new Map(template.columns.map((column) => [column.name, column])); + const normalized: Record = {}; + + for (const [key, value] of Object.entries(rawFields)) { + if (["id", "nome", "image_url", "image_local_path"].includes(key)) { + continue; + } + + const column = columnMap.get(key); + if (!column) { + continue; + } + + if (value === "" || value === null || value === undefined) { + normalized[key] = null; + continue; + } + + switch (column.type) { + case "INTEGER": + case "BOOLEAN": { + const parsed = Number(value); + normalized[key] = Number.isFinite(parsed) ? parsed : null; + break; + } + case "REAL": { + const parsed = Number(value); + normalized[key] = Number.isFinite(parsed) ? parsed : null; + break; + } + default: + normalized[key] = String(value); + } + } + + return normalized; +} + +export async function GET(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + const { searchParams } = new URL(request.url); + const page = parseNumber(searchParams.get("page"), 1); + const pageSize = Math.min(parseNumber(searchParams.get("pageSize"), 50), 200); + const q = searchParams.get("q") || undefined; + const category = searchParams.get("category") || undefined; + const rarity = searchParams.get("rarity") || undefined; + const server = searchParams.get("server") || undefined; + + try { + const result = await searchAdminItems({ + q, + category, + rarity, + server, + page, + pageSize, + }); + + return NextResponse.json(result, { status: 200 }); + } catch (error: any) { + console.error("[admin/items][GET] erro", error?.message); + return NextResponse.json( + { + success: false, + error: "Falha ao consultar itens", + message: error?.message || "Erro desconhecido", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : ""; + + if (!name || !category) { + return NextResponse.json( + { success: false, error: "Nome e categoria são obrigatórios." }, + { status: 400 } + ); + } + + const dynamicFields = normalizeDynamicFields(category, body?.fields); + + const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() || null : null; + const imageLocalPath = + typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() || null : null; + + const sqliteData = { + nome: name, + image_url: imageUrl, + image_local_path: imageLocalPath, + ...dynamicFields, + } as Record; + + const sqliteId = insertCategoryItem({ category, data: sqliteData }); + + const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {}; + const metadata = { ...metadataBase, ...dynamicFields } as Record; + + const item = await createAdminItem({ + item_name: name, + category, + rarity: typeof body?.rarity === "string" ? body.rarity.trim() || null : null, + image_url: imageUrl, + image_local_path: imageLocalPath, + level: typeof body?.level === "number" ? body.level : null, + weight: typeof body?.weight === "number" ? body.weight : null, + slots: typeof body?.slots === "number" ? body.slots : null, + skill: typeof body?.skill === "string" ? body.skill.trim() || null : null, + servers: parseList(body?.servers), + tags: parseList(body?.tags), + description: + typeof body?.description === "string" ? body.description.trim() || null : null, + metadata, + source_table_name: category, + source_table_id: sqliteId, + }); + + return NextResponse.json( + { + success: true, + item, + }, + { status: 201 } + ); + } catch (error: any) { + console.error("[admin/items][POST] erro", error?.message); + return NextResponse.json( + { + success: false, + error: "Falha ao criar item", + message: error?.message || "Erro desconhecido", + }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const id = Number(body?.id); + + if (!Number.isFinite(id) || id <= 0) { + return NextResponse.json( + { success: false, error: "ID inválido." }, + { status: 400 } + ); + } + + const existing = await getItemById(id); + if (!existing) { + return NextResponse.json( + { success: false, error: "Item não encontrado." }, + { status: 404 } + ); + } + + const category = (typeof body?.category === "string" ? body.category.trim().toLowerCase() : existing.category) ?? existing.category; + if (category !== existing.category) { + return NextResponse.json( + { success: false, error: "Alterar a categoria de um item não é suportado." }, + { status: 400 } + ); + } + + const dynamicFields = normalizeDynamicFields(category, body?.fields); + + const imageUrl = typeof body?.imageUrl === "string" ? body.imageUrl.trim() : undefined; + const imageLocalPath = + typeof body?.imageLocalPath === "string" ? body.imageLocalPath.trim() : undefined; + + if (existing.source_table_id && existing.source_table_name) { + const sqliteData: Record = { + nome: typeof body?.name === "string" ? body.name.trim() : existing.item_name, + }; + + if (imageUrl !== undefined) sqliteData.image_url = imageUrl; + else sqliteData.image_url = existing.image_url; + + if (imageLocalPath !== undefined) sqliteData.image_local_path = imageLocalPath; + else sqliteData.image_local_path = existing.image_local_path; + + for (const [key, value] of Object.entries(dynamicFields)) { + sqliteData[key] = value; + } + + updateCategoryItem(existing.source_table_name, existing.source_table_id, sqliteData); + } + + const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : existing.metadata; + const metadata = { ...metadataBase, ...dynamicFields } as Record; + + const item = await updateAdminItem(id, { + item_name: typeof body?.name === "string" ? body.name.trim() : undefined, + category, + rarity: typeof body?.rarity === "string" ? body.rarity.trim() : undefined, + image_url: imageUrl, + image_local_path: imageLocalPath, + level: typeof body?.level === "number" ? body.level : undefined, + weight: typeof body?.weight === "number" ? body.weight : undefined, + slots: typeof body?.slots === "number" ? body.slots : undefined, + skill: typeof body?.skill === "string" ? body.skill.trim() : undefined, + servers: parseList(body?.servers), + tags: parseList(body?.tags), + description: + typeof body?.description === "string" + ? body.description.trim() + : body?.description === null + ? null + : undefined, + metadata, + }); + + return NextResponse.json( + { + success: true, + item, + }, + { status: 200 } + ); + } catch (error: any) { + console.error("[admin/items][PUT] erro", error?.message); + return NextResponse.json( + { + success: false, + error: "Falha ao atualizar item", + message: error?.message || "Erro desconhecido", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/items/categories/route.ts b/app/api/items/categories/route.ts index f507459..37bb1f2 100644 --- a/app/api/items/categories/route.ts +++ b/app/api/items/categories/route.ts @@ -1,24 +1,24 @@ import { NextRequest } from "next/server"; -import path from "node:path"; - -function getDb() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const Database = require("better-sqlite3"); - const dbPath = path.join(process.cwd(), "data", "tibiawiki.db"); - return new Database(dbPath, { readonly: true, fileMustExist: true }); -} +import { prisma } from "@/lib/prisma/app"; export async function GET(_req: NextRequest) { try { - const db = getDb(); - const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name <> 'sqlite_sequence' ORDER BY name") - .all() as { name: string }[]; - db.close(); - // Retorna nomes das tabelas (PT) para usar como categorias - return Response.json({ categories: tables.map((t) => t.name) }); + const categories = await prisma.item.findMany({ + distinct: ["category"], + select: { category: true }, + orderBy: { category: "asc" }, + }); + + return Response.json({ + categories: categories + .map((row) => row.category) + .filter((category): category is string => Boolean(category)), + }); } catch (e: any) { - return Response.json({ error: e?.message || "Erro" }, { status: 500 }); + return Response.json( + { error: e?.message || "Erro" }, + { status: 500 } + ); } } diff --git a/app/api/items/route.ts b/app/api/items/route.ts index 06b2a97..e2991c0 100644 --- a/app/api/items/route.ts +++ b/app/api/items/route.ts @@ -1,60 +1,66 @@ import { NextRequest } from "next/server"; -import { searchItems } from "@/lib/items-db"; +import { searchItems, type ItemSort } from "@/lib/prisma/queries/items"; import { buildPublicItemImageUrl } from "@/lib/item-images"; function parseArray(param: string | null): string[] { if (!param) return []; - // Suporta formatos: comma-separated (a,b) ou múltiplos parâmetros já combinados return param .split(",") - .map((v) => v.trim()) + .map((value) => value.trim()) .filter(Boolean); } +const allowedSorts: ItemSort[] = [ + "name", + "level-asc", + "level-desc", + "weight-asc", + "weight-desc", + "category", + "updated-desc", +]; + export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const q = searchParams.get("q") || undefined; const categories = parseArray(searchParams.get("category")); const rarities = parseArray(searchParams.get("rarity")); - const sortParam = (searchParams.get("sort") || "name") as - | "name" - | "level-asc" - | "level-desc" - | "weight-asc" - | "weight-desc" - | "category"; + const server = searchParams.get("server") || undefined; + const sortParamRaw = (searchParams.get("sort") || "name").toLowerCase(); + const sortParam = (allowedSorts.find((sort) => sort === sortParamRaw) || "name") as ItemSort; const page = Number(searchParams.get("page") || "1"); const pageSize = Number(searchParams.get("pageSize") || "24"); - const voc = searchParams.get("voc") || undefined; - const { items, total } = searchItems({ + const { items, total } = await searchItems({ q, - categories, - rarities, + category: categories[0], + rarity: rarities[0], + server, sort: sortParam, - vocation: voc, page, pageSize, }); - // Enriquecer com URL pública de imagem quando possível - const itemsWithImage = items.map((it: any) => { - const img = (it.image || "").toString(); - // Preferir URL absoluta do banco para teste + const itemsWithImage = items.map((item) => { + const img = (item.image_url || item.image_local_path || "").toString(); if (/^https?:\/\//i.test(img)) { - return { ...it, image: img }; + return { ...item, image_url: img, image: img }; } if (img.startsWith("data/")) { - return { ...it, image: `/api/images?path=${encodeURIComponent(img)}` }; + return { ...item, image_url: img, image: `/api/images?path=${encodeURIComponent(img)}` }; } - const categoryEn = (it.category || "other").toString(); - return { ...it, image: buildPublicItemImageUrl(categoryEn, img) }; + const category = item.category || "other"; + return { + ...item, + image_url: item.image_url, + image: buildPublicItemImageUrl(category, img), + }; }); const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); return Response.json( - { items: itemsWithImage, page, pageSize, total, totalPages }, + { items: itemsWithImage, page, pageSize, total, totalPages, sort: sortParam }, { status: 200 } ); } catch (error: any) { diff --git a/components/Header.tsx b/components/Header.tsx index d7c3ce7..090f060 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -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" }] : []), ]; diff --git a/components/admin/AdminPanelTabs.tsx b/components/admin/AdminPanelTabs.tsx new file mode 100644 index 0000000..23ba9e3 --- /dev/null +++ b/components/admin/AdminPanelTabs.tsx @@ -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 ( + setActiveTab(value as typeof activeTab)} + className="space-y-6" + > + + Visão Geral + Assinaturas & Permissões + Gerenciamento de Itens + + + + + + Bem-vindo, {adminUser?.username ?? "Administrador"} + + +

+ Utilize as abas acima para administrar planos de assinatura, permissões + e a curadoria de itens do marketplace. Esta área é exclusiva para + operadores autorizados. +

+

+ 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. +

+
+
+
+ + + + + + + + +
+ ); +} + diff --git a/components/admin/items/AdminItemManager.tsx b/components/admin/items/AdminItemManager.tsx new file mode 100644 index 0000000..1fdae69 --- /dev/null +++ b/components/admin/items/AdminItemManager.tsx @@ -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; + 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(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [serverFilter, setServerFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [rarityFilter, setRarityFilter] = useState("all"); + const [page, setPage] = useState(initialData.page ?? 1); + const pageSize = initialData.pageSize ?? 50; + + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"create" | "edit">("create"); + const [selectedItem, setSelectedItem] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const searchDebounce = useRef(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(); + 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 = { + 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 ( +
+ + +
+ Todos os itens cadastrados + + Visualize e edite os registros unificados importados do database do Tibia Wiki. + Itens específicos de servidores podem ser marcados através das tags. + +
+
+ + +
+
+
+ + + +
+ setSearch(event.target.value)} + /> + + +
+ {loading ? "Carregando..." : `${data.total} itens encontrados`} +
+
+
+
+ + {error && ( + + {error} + + )} + + {loading ? ( +
+ + +
+ ) : ( + { + setServerFilter(value); + setPage(1); + }} + className="space-y-4" + > + + {serverTabs.map((server) => ( + + {server === "all" ? "Todos" : server} + + ))} + + + {serverTabs.map((server) => ( + +
+ + + + + + + + + + + + + + + {itemsForDisplay.length === 0 ? ( + + + + ) : ( + itemsForDisplay.map((item) => ( + + + + + + + + + + + )) + )} + +
ImagemNomeCategoriaRaridadeOrigemServidoresAtualizado emAções
+ Nenhum item encontrado com os filtros atuais. +
+ {item.image_url ? ( +
+ {item.item_name} +
+ ) : ( + Sem imagem + )} +
+
{item.item_name}
+
+ #{item.item_id} +
+
{item.category} + {item.rarity ? ( + + {item.rarity} + + ) : ( + - + )} + +
{item.source_table_name}
+
ID origem: {item.source_table_id}
+
+ {item.servers.length > 0 ? ( +
+ {item.servers.map((serverTag) => ( + + {serverTag} + + ))} +
+ ) : ( + Global + )} +
+ {format(new Date(item.updated_at), "dd/MM/yyyy HH:mm", { locale: ptBR })} + + +
+
+
+ + Página {page} • Total de {Math.max(1, Math.ceil(data.total / pageSize))} + +
+ + +
+
+
+ ))} +
+ )} + + +
+ ); +} + diff --git a/components/admin/items/ItemCreationModal.tsx b/components/admin/items/ItemCreationModal.tsx new file mode 100644 index 0000000..f88b9ad --- /dev/null +++ b/components/admin/items/ItemCreationModal.tsx @@ -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; + fields?: Record; +} + +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; + }; + categories: string[]; + serverOptions: string[]; + onSubmit: (payload: ItemFormPayload) => Promise; + submitting?: boolean; +} + +type AnalysisResult = { + table: string; + columns: Record; +} | 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(null); + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [submitStatus, setSubmitStatus] = useState(null); + const allowAiTools = !isEdit; + + const [categoryTemplates, setCategoryTemplates] = useState([]); + const [categoryLoading, setCategoryLoading] = useState(false); + const [categoryError, setCategoryError] = useState(null); + const [dynamicValues, setDynamicValues] = useState>({}); + const [showNewCategoryModal, setShowNewCategoryModal] = useState(false); + const [showNewFieldModal, setShowNewFieldModal] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(""); + const [newCategoryFields, setNewCategoryFields] = useState>([]); + const [newCategoryError, setNewCategoryError] = useState(null); + const [newFieldName, setNewFieldName] = useState(""); + const [newFieldType, setNewFieldType] = useState("TEXT"); + const [newFieldNotNull, setNewFieldNotNull] = useState(false); + const [newFieldError, setNewFieldError] = useState(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(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) => { + if (!template) return {} as Record; + const values: Record = {}; + 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 = {}; + 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 | 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 ( + <> + + +
+ + {isEdit ? "Editar item" : "Novo item"} + + Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar. + + + +
+
+
+
+ + setName(event.target.value)} + /> +
+ +
+
+ +
+ + + +
+ {categoryError && ( +

{categoryError}

+ )} + {isEdit && ( +

+ A categoria existente do item não pode ser alterada neste fluxo. +

+ )} +
+
+ + setRarity(event.target.value)} + /> +
+
+ +
+
+ + setImageUrl(event.target.value)} + /> +
+
+ + setImageLocalPath(event.target.value)} + /> +
+
+ +
+
+ + setLevel(event.target.value)} + /> +
+
+ + setWeight(event.target.value)} + /> +
+
+ +
+
+ + setSlots(event.target.value)} + /> +
+
+ + setSkill(event.target.value)} + /> +
+
+ + {dynamicColumns.length > 0 && ( +
+
+ +

+ Defina valores adicionais conforme o modelo da categoria selecionada. +

+
+
+ {dynamicColumns.map((column) => { + const value = dynamicValues[column.name]; + if (column.type === "BOOLEAN") { + return ( +
+ + handleDynamicValueChange(column, checked === true) + } + /> + +
+ ); + } + + const inputType = column.type === "TEXT" ? "text" : "number"; + const step = column.type === "REAL" ? "0.01" : "1"; + + return ( +
+ + + handleDynamicValueChange(column, event.target.value) + } + /> +
+ ); + })} +
+
+ )} + +
+ + setServersCsv(event.target.value)} + /> + {serverSuggestions && ( +

+ Sugestões cadastradas: {serverSuggestions} +

+ )} +
+ +
+ + setTagsCsv(event.target.value)} + /> +
+ +
+ +