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)} + /> +
+ +
+ +