diff --git a/app/[locale]/admin/items/new/page.tsx b/app/[locale]/admin/items/new/page.tsx index 06f68b7..8b124c1 100644 --- a/app/[locale]/admin/items/new/page.tsx +++ b/app/[locale]/admin/items/new/page.tsx @@ -6,6 +6,7 @@ import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePag import { getSession } from "@/lib/redis/session"; import { getUserById } from "@/lib/prisma/queries/users"; import { searchAdminItems } from "@/lib/prisma/queries/items"; +import { getActiveServers } from "@/lib/prisma/queries/servers"; export async function generateMetadata(): Promise { return { @@ -38,7 +39,10 @@ export default async function AdminNewItemPage({ redirect(`/${locale}`); } - const initialItems = await searchAdminItems({ page: 1, pageSize: 1 }); + const [initialItems, activeServers] = await Promise.all([ + searchAdminItems({ page: 1, pageSize: 1 }), + getActiveServers(), + ]); return (
@@ -47,7 +51,7 @@ export default async function AdminNewItemPage({ s.server_name)} />
diff --git a/app/api/admin/items/analyze/route.ts b/app/api/admin/items/analyze/route.ts index c232e66..bea325b 100644 --- a/app/api/admin/items/analyze/route.ts +++ b/app/api/admin/items/analyze/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAdminUser } from "@/lib/auth/middleware"; import { generateItemStructure } from "@/lib/ai/generate-item-structure"; +import { calculateTokenCost } from "@/lib/ai/token-pricing"; +import { recordTokenUsage } from "@/lib/ai/token-usage"; export async function POST(request: NextRequest) { const auth = await requireAdminUser(request); @@ -26,12 +28,16 @@ export async function POST(request: NextRequest) { const images: string[] = Array.isArray(body?.images) ? body.images.filter((item: unknown) => typeof item === "string") : []; + const hasDescription = !!description; + const hasImages = images.length > 0; - if (!name || !description) { + // Nova regra: aceitar quando houver descrição OU ao menos uma imagem. + // Nome pode ser vazio; o assistente do front lida com sugestão/confirmar_nome. + if (!hasDescription && !hasImages) { return NextResponse.json( { success: false, - error: "Informe nome e descrição para gerar o JSON estrutural.", + error: "Forneça descrição ou ao menos uma imagem para análise.", }, { status: 400 } ); @@ -57,10 +63,46 @@ export async function POST(request: NextRequest) { .filter((image) => image.base64Data), }); + // Cálculo de custo baseado em usageMetadata + const model = (result.model ?? "gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING; + const breakdown = calculateTokenCost("gemini-2.5-flash", result.usage); + + // Persistir histórico para relatórios (não bloqueia resposta; erros são logados no Redis util) + const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + try { + await recordTokenUsage({ + id: requestId, + timestamp: Date.now(), + model: result.model ?? "gemini-2.5-flash", + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + totalTokens: breakdown.totalTokens, + costUsd: breakdown.totalUsd, + context: { + route: "/api/admin/items/analyze", + scope, + server, + hasImages, + hasDescription, + categoriesCount: categories.length, + }, + }); + } catch { + // Falhas de Redis não devem impedir a análise + } + return NextResponse.json( { success: true, structure: result.structure, + usage: { + promptTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + totalTokens: breakdown.totalTokens, + }, + costUsd: breakdown.totalUsd, + model: result.model ?? "gemini-2.5-flash", + requestId, }, { status: 200 } ); diff --git a/app/api/admin/items/route.ts b/app/api/admin/items/route.ts index 313dc02..3914645 100644 --- a/app/api/admin/items/route.ts +++ b/app/api/admin/items/route.ts @@ -129,6 +129,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const name = typeof body?.name === "string" ? body.name.trim() : ""; const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : ""; + const originServer = typeof body?.server === "string" ? body.server.trim() || null : null; if (!name || !category) { return NextResponse.json( @@ -150,11 +151,19 @@ export async function POST(request: NextRequest) { ...dynamicFields, } as Record; + // Se enviado servidor de origem, inclui na linha do SQLite e garante presença na lista de servidores do item + if (originServer) { + sqliteData.server = originServer; + } + const sqliteId = insertCategoryItem({ category, data: sqliteData }); const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {}; const metadata = { ...metadataBase, ...dynamicFields } as Record; + const itemServers = parseList(body?.servers); + const finalServers = originServer ? Array.from(new Set([originServer, ...itemServers])) : itemServers; + const item = await createAdminItem({ item_name: name, category, @@ -165,7 +174,7 @@ export async function POST(request: NextRequest) { 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), + servers: finalServers, tags: parseList(body?.tags), description: typeof body?.description === "string" ? body.description.trim() || null : null, diff --git a/components/admin/items/AdminItemCreatePage.tsx b/components/admin/items/AdminItemCreatePage.tsx index 2b0f76a..381633d 100644 --- a/components/admin/items/AdminItemCreatePage.tsx +++ b/components/admin/items/AdminItemCreatePage.tsx @@ -43,6 +43,7 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin tags: payload.tags, metadata: payload.metadata, fields: payload.fields, + server: payload.server ?? null, }; const response = await fetch("/api/admin/items", { diff --git a/components/admin/items/AdminItemCreateView.tsx b/components/admin/items/AdminItemCreateView.tsx index 1fc025e..3e1e8e5 100644 --- a/components/admin/items/AdminItemCreateView.tsx +++ b/components/admin/items/AdminItemCreateView.tsx @@ -32,6 +32,7 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin tags: payload.tags, metadata: payload.metadata, fields: payload.fields, + server: payload.server ?? null, }; const response = await fetch("/api/admin/items", { diff --git a/components/admin/items/AdminItemForm.tsx b/components/admin/items/AdminItemForm.tsx index 96488ae..d0ea48c 100644 --- a/components/admin/items/AdminItemForm.tsx +++ b/components/admin/items/AdminItemForm.tsx @@ -67,6 +67,7 @@ export interface ItemFormPayload { tags: string[]; metadata?: Record; fields?: Record; + server?: string | null; } interface ItemReference { @@ -190,10 +191,22 @@ export function AdminItemForm({ const [skill, setSkill] = useState(""); const [serversCsv, setServersCsv] = useState(""); const [tagsCsv, setTagsCsv] = useState(""); + const [originServer, setOriginServer] = useState(""); const [analysis, setAnalysis] = useState(null); const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisError, setAnalysisError] = useState(null); + const [tokenUsage, setTokenUsage] = useState<{ + promptTokens: number; + outputTokens: number; + totalTokens: number; + costUsd: number; + } | null>(null); + const [accumulatedCostUsd, setAccumulatedCostUsd] = useState(() => { + if (typeof window === "undefined") return 0; + const stored = window.localStorage.getItem("aiCostAccumulatedUsd"); + return stored ? Number(stored) || 0 : 0; + }); const [submitStatus, setSubmitStatus] = useState(null); const [categoryTemplates, setCategoryTemplates] = useState([]); @@ -216,6 +229,27 @@ export function AdminItemForm({ const dynamicInitializedRef = useRef(false); const lastCategoryRef = useRef(null); const [pendingPrefill, setPendingPrefill] = useState(null); + const [unsavedFields, setUnsavedFields] = useState>(new Set()); + const [showPrefillModal, setShowPrefillModal] = useState(false); + type DiffEntry = { field: string; current: string | null; next: string | null }; + const [prefillDiff, setPrefillDiff] = useState<{ changed: DiffEntry[]; added: DiffEntry[]; unchanged: DiffEntry[] }>({ + changed: [], + added: [], + unchanged: [], + }); + const [showExitConfirm, setShowExitConfirm] = useState(false); + const [aiSuggestedName, setAiSuggestedName] = useState(false); + const [confirmNome, setConfirmNome] = useState(false); + + const markUnsaved = useCallback((key: string) => { + setUnsavedFields((prev) => { + const next = new Set(prev); + next.add(key); + return next; + }); + }, []); + + const isUnsaved = useCallback((key: string) => unsavedFields.has(key), [unsavedFields]); const resetFormState = useCallback( (templateList: CategoryTemplate[]) => { @@ -415,7 +449,7 @@ export function AdminItemForm({ name: name.trim(), description: description.trim(), scope: serverOptions.length > 0 ? "server" : "global", - server: serverOptions[0] ?? null, + server: originServer || (serverOptions[0] ?? null), }), }); @@ -425,6 +459,23 @@ export function AdminItemForm({ const data = await res.json(); setAnalysis(data.structure ?? null); + if (data?.usage && typeof data?.costUsd === "number") { + setTokenUsage({ + promptTokens: Number(data.usage.promptTokens || 0), + outputTokens: Number(data.usage.outputTokens || 0), + totalTokens: Number(data.usage.totalTokens || 0), + costUsd: Number(data.costUsd || 0), + }); + setAccumulatedCostUsd((prev) => { + const next = Number(prev) + Number(data.costUsd || 0); + try { + if (typeof window !== "undefined") { + window.localStorage.setItem("aiCostAccumulatedUsd", String(next)); + } + } catch {} + return next; + }); + } } catch (error) { console.error(error); setAnalysisError( @@ -449,6 +500,7 @@ export function AdminItemForm({ dynamicInitializedRef.current = false; lastCategoryRef.current = null; setCategory(value.toLowerCase()); + markUnsaved("category"); }, []); const handleAddCategoryField = useCallback(() => { @@ -587,20 +639,134 @@ export function AdminItemForm({ const submitLabel = isEdit ? "Salvar alterações" : "Criar item"; + const normalizeValue = (value: unknown): string | null => { + if (value === null || value === undefined) return null; + if (Array.isArray(value)) return value.join(", "); + return String(value); + }; + + const getCurrentSnapshot = useCallback(() => { + return { + name, + category, + rarity, + imageUrl, + imageLocalPath, + level, + weight, + slots, + skill, + servers: parseCsv(serversCsv), + tags: parseCsv(tagsCsv), + description, + metadata: { ...prefilledMetadata, ...dynamicValues }, + server: originServer || null, + }; + }, [name, category, rarity, imageUrl, imageLocalPath, level, weight, slots, skill, serversCsv, tagsCsv, description, prefilledMetadata, dynamicValues, originServer]); + + const computePrefillDiff = useCallback((prefill: PrefillPayload) => { + const current = getCurrentSnapshot(); + const changed: DiffEntry[] = []; + const added: DiffEntry[] = []; + const unchanged: DiffEntry[] = []; + + const topKeys: Array = [ + "name", + "category", + "rarity", + "imageUrl", + "imageLocalPath", + "level", + "weight", + "slots", + "skill", + "servers", + "tags", + "description", + ]; + + for (const key of topKeys) { + if (prefill[key] === undefined) continue; + const next = normalizeValue( + key === "servers" || key === "tags" + ? Array.isArray(prefill[key]) + ? (prefill[key] as string[]) + : (String(prefill[key] ?? "").split(",").map((v) => v.trim()).filter(Boolean)) + : prefill[key] + ); + const currentVal = normalizeValue((current as any)[key]); + const field = key === "servers" ? "servers" : key === "tags" ? "tags" : (key as string); + if (currentVal !== next) { + if (currentVal === null || currentVal === "") { + added.push({ field, current: currentVal, next }); + } else { + changed.push({ field, current: currentVal, next }); + } + } else { + unchanged.push({ field, current: currentVal, next }); + } + } + + const meta = prefill.metadata && typeof prefill.metadata === "object" ? prefill.metadata : {}; + for (const [k, v] of Object.entries(meta as Record)) { + if (k === "category") continue; + const currentVal = normalizeValue(current.metadata[k]); + const next = normalizeValue(v); + const field = `meta.${k}`; + if (currentVal !== next) { + if (currentVal === null || currentVal === "") { + added.push({ field, current: currentVal, next }); + } else { + changed.push({ field, current: currentVal, next }); + } + } else { + unchanged.push({ field, current: currentVal, next }); + } + } + + return { changed, added, unchanged }; + }, [getCurrentSnapshot]); + + const markUnsavedFromDiff = useCallback((diff: { changed: DiffEntry[]; added: DiffEntry[] }) => { + const next = new Set(); + for (const d of [...diff.changed, ...diff.added]) { + next.add(d.field); + } + setUnsavedFields(next); + }, []); + + const renderUnsavedTag = (field: string) => + unsavedFields.has(field) ? ( + Não salvo + ) : null; + const handlePrimarySubmit = useCallback(async () => { if (!name.trim()) { setSubmitStatus("Informe o nome do item."); return; } + if (aiSuggestedName && !confirmNome) { + setSubmitStatus("Confirme o nome sugerido pela IA."); + return; + } + if (!category.trim()) { setSubmitStatus("Informe a categoria do item."); return; } + if (!originServer || !originServer.trim()) { + setSubmitStatus("Selecione o servidor de origem."); + return; + } + setSubmitStatus(null); try { const metadataPayload: Record = { ...prefilledMetadata }; + if (aiSuggestedName) { + metadataPayload.confirmar_nome = confirmNome; + } if (allowAiTools && analysis) { metadataPayload.aiStructure = analysis; } @@ -624,6 +790,7 @@ export function AdminItemForm({ tags: parseCsv(tagsCsv), metadata: metadataPayload, fields: dynamicValues, + server: originServer, }); setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso."); @@ -671,9 +838,39 @@ export function AdminItemForm({ void handlePrimarySubmit(); }; + const handleCancelClick = useCallback(() => { + if (unsavedFields.size > 0) { + setShowExitConfirm(true); + return; + } + onCancel?.(); + }, [unsavedFields, onCancel]); + const formClassName = className ?? "flex h-full flex-col"; const bodyClassName = contentClassName ?? "flex-1 min-h-0 overflow-y-auto px-6 py-4"; + const [prefillLoading, setPrefillLoading] = useState(false); + const [prefillAnimating, setPrefillAnimating] = useState(false); + const [prefillStatus, setPrefillStatus] = useState(null); + + const formattedAnalysis = useMemo(() => { + if (!analysis) return null; + try { + const cloned: any = JSON.parse(JSON.stringify(analysis)); + const cols = cloned?.columns; + if (cols && typeof cols.description === "string") { + const s = cols.description as string; + if (s.length > 140) { + // Insere quebras de linha após vírgulas para melhorar legibilidade + cols.description = s.replace(/,\s+/g, ",\n"); + } + } + return cloned; + } catch { + return analysis; + } + }, [analysis]); + const applyPrefill = useCallback( (prefill: PrefillPayload) => { if (prefill.name !== undefined) setName(prefill.name ?? ""); @@ -717,6 +914,11 @@ export function AdminItemForm({ if (template) { setDynamicValues(buildDynamicInitialValues(template, prefill.metadata)); } + + const origem = typeof prefill.metadata?.origem_dados === "string" ? (prefill.metadata as any).origem_dados : null; + const hasAutoName = prefill.name !== undefined && String(prefill.name ?? "").trim().length > 0; + setAiSuggestedName(Boolean(hasAutoName && origem === "automatico")); + setConfirmNome(false); } }, [ @@ -735,13 +937,36 @@ export function AdminItemForm({ useEffect(() => { if (!pendingPrefill) return; if (categoryTemplates.length === 0) return; + const diff = computePrefillDiff(pendingPrefill); + setPrefillDiff(diff); + setShowPrefillModal(true); + }, [pendingPrefill, categoryTemplates, computePrefillDiff]); + + const handleConfirmPrefill = useCallback(() => { + if (!pendingPrefill) return; applyPrefill(pendingPrefill); + const diff = computePrefillDiff(pendingPrefill); + markUnsavedFromDiff(diff); setPendingPrefill(null); - }, [applyPrefill, pendingPrefill, categoryTemplates]); + setShowPrefillModal(false); + }, [pendingPrefill, applyPrefill, computePrefillDiff, markUnsavedFromDiff]); + + const hasUnsaved = unsavedFields.size > 0; + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (hasUnsaved) { + e.preventDefault(); + e.returnValue = ""; + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [hasUnsaved]); return ( <> -
+ {headerSlot?.({ title, description: descriptionText }) ?? (
@@ -754,18 +979,42 @@ export function AdminItemForm({
- + setName(event.target.value)} + onChange={(event) => { + setName(event.target.value); + setAiSuggestedName(false); + setConfirmNome(false); + markUnsaved("name"); + }} /> + {aiSuggestedName && ( +
+ { + setConfirmNome(Boolean(checked)); + markUnsaved("confirmar_nome"); + }} + /> + +
+ )}
- +
setRarity(event.target.value)} + onChange={(event) => { + setRarity(event.target.value); + markUnsaved("rarity"); + }} />
- + setImageUrl(event.target.value)} + onChange={(event) => { + setImageUrl(event.target.value); + markUnsaved("imageUrl"); + }} />
- + setImageLocalPath(event.target.value)} + onChange={(event) => { + setImageLocalPath(event.target.value); + markUnsaved("imageLocalPath"); + }} />
- + setLevel(event.target.value)} + onChange={(event) => { + setLevel(event.target.value); + markUnsaved("level"); + }} />
- + setWeight(event.target.value)} + onChange={(event) => { + setWeight(event.target.value); + markUnsaved("weight"); + }} />
- + setSlots(event.target.value)} + onChange={(event) => { + setSlots(event.target.value); + markUnsaved("slots"); + }} />
- + setSkill(event.target.value)} + onChange={(event) => { + setSkill(event.target.value); + markUnsaved("skill"); + }} />
@@ -899,7 +1183,11 @@ export function AdminItemForm({ {dynamicColumns.length > 0 && (
- +

Defina valores adicionais conforme o modelo da categoria selecionada.

@@ -918,7 +1206,9 @@ export function AdminItemForm({ } />
); @@ -930,7 +1220,9 @@ export function AdminItemForm({ return (
- handleDynamicValueChange(column, event.target.value) - } + onChange={(event) => { + handleDynamicValueChange(column, event.target.value); + markUnsaved(`fields.${column.name}`); + }} />
); @@ -955,12 +1248,42 @@ export function AdminItemForm({ )}
- + + + {!originServer && ( +

Campo obrigatório. Selecione o servidor de origem.

+ )} +
+ +
+ setServersCsv(event.target.value)} + onChange={(event) => { + setServersCsv(event.target.value); + markUnsaved("servers"); + }} /> {serverSuggestions && (

@@ -970,24 +1293,34 @@ export function AdminItemForm({

- + setTagsCsv(event.target.value)} + onChange={(event) => { + setTagsCsv(event.target.value); + markUnsaved("tags"); + }} />
- -