Files
pagodera/components/admin/items/AdminItemForm.tsx
T
2025-11-11 03:08:36 -03:00

2386 lines
83 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
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"];
const categoriesCache: {
data: CategoryTemplate[] | null;
pending: Promise<CategoryTemplate[] | null> | null;
} = {
data: null,
pending: null,
};
function generateTempId() {
return Math.random().toString(36).slice(2, 10);
}
export interface ItemFormPayload {
id?: number;
name: string;
category: string;
rarity?: string | null;
imageUrl?: string | null;
imageLocalPath?: string | null;
level?: number | null;
weight?: number | null;
slots?: number | null;
skill?: string | null;
description?: string | null;
servers: string[];
tags: string[];
metadata?: Record<string, any>;
fields?: Record<string, string | boolean | null>;
server?: string | null;
}
interface ItemReference {
item_id: number;
item_name: string;
category: string;
rarity: string | null;
image_url: string | null;
image_local_path: string | null;
level: number | null;
weight: number | null;
slots: number | null;
skill: string | null;
description?: string | null;
servers: string[];
tags: string[];
metadata: Record<string, any>;
}
type AnalysisResult = {
table: string;
columns: Record<string, unknown>;
} | null;
function parseCsv(input: string): string[] {
return input
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function serializeCsv(list: string[]): string {
if (!Array.isArray(list) || list.length === 0) return "";
return list.join(", ");
}
function parseNumberInput(value: string): number | null {
if (!value) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
interface HeaderSlotProps {
title: string;
description: string;
}
interface FooterSlotProps {
isEdit: boolean;
submitting?: boolean;
submitLabel: string;
onCancel?: () => void;
onSubmitClick: () => void;
}
type PrefillPayload = Partial<{
name: string;
category: string;
rarity: string | null;
imageUrl: string | null;
imageLocalPath: string | null;
level: number | string | null;
weight: number | string | null;
slots: number | string | null;
skill: string | null;
servers: string[] | string;
tags: string[] | string;
description: string | null;
metadata: Record<string, unknown>;
}>;
export interface AdminItemFormProps {
mode: "create" | "edit";
item?: ItemReference;
categories: string[];
serverOptions: string[];
submitting?: boolean;
onSubmit: (payload: ItemFormPayload) => Promise<void>;
onCancel?: () => void;
onSuccess?: () => void;
isActive?: boolean;
allowAiTools?: boolean;
prefillData?: PrefillPayload | null;
className?: string;
contentClassName?: string;
headerSlot?: (props: HeaderSlotProps) => React.ReactNode;
footerSlot?: (props: FooterSlotProps) => React.ReactNode;
}
export function AdminItemForm({
mode,
item,
categories,
serverOptions,
submitting,
onSubmit,
onCancel,
onSuccess,
isActive = true,
allowAiTools: allowAiToolsProp,
prefillData,
className,
contentClassName,
headerSlot,
footerSlot,
}: AdminItemFormProps) {
const isEdit = mode === "edit";
const allowAiTools = allowAiToolsProp ?? !isEdit;
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 [originServer, setOriginServer] = useState<string>("");
const [analysis, setAnalysis] = useState<AnalysisResult>(null);
const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [tokenUsage, setTokenUsage] = useState<{
promptTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
} | null>(null);
const [accumulatedCostUsd, setAccumulatedCostUsd] = useState<number>(() => {
if (typeof window === "undefined") return 0;
const stored = window.localStorage.getItem("aiCostAccumulatedUsd");
return stored ? Number(stored) || 0 : 0;
});
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
const [categoryTemplates, setCategoryTemplates] = useState<
CategoryTemplate[]
>([]);
const [categoryLoading, setCategoryLoading] = useState(false);
const [categoryError, setCategoryError] = useState<string | null>(null);
const [dynamicValues, setDynamicValues] = useState<
Record<string, string | boolean | null>
>({});
const [prefilledMetadata, setPrefilledMetadata] = useState<
Record<string, unknown>
>({});
const [showNewCategoryModal, setShowNewCategoryModal] = useState(false);
const [showNewFieldModal, setShowNewFieldModal] = useState(false);
const [newCategoryName, setNewCategoryName] = useState("");
const [newCategoryFields, setNewCategoryFields] = useState<
Array<{
id: string;
name: string;
type: CategoryColumn["type"];
notNull: boolean;
}>
>([]);
const [newCategoryError, setNewCategoryError] = useState<string | null>(null);
const [newFieldName, setNewFieldName] = useState("");
const [newFieldType, setNewFieldType] =
useState<CategoryColumn["type"]>("TEXT");
const [newFieldNotNull, setNewFieldNotNull] = useState(false);
const [newFieldError, setNewFieldError] = useState<string | null>(null);
const dynamicInitializedRef = useRef(false);
const lastCategoryRef = useRef<string | null>(null);
const [pendingPrefill, setPendingPrefill] = useState<PrefillPayload | null>(
null,
);
const [unsavedFields, setUnsavedFields] = useState<Set<string>>(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[]) => {
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));
} else {
const defaultCategory = templateList[0]?.name ?? categories[0] ?? "";
setName("");
setCategory(defaultCategory.toLowerCase());
setRarity("");
setImageUrl("");
setImageLocalPath("");
setDescription("");
setLevel("");
setWeight("");
setSlots("");
setSkill("");
setServersCsv("");
setTagsCsv("");
}
setSubmitStatus(null);
setAnalysis(null);
setAnalysisError(null);
setCategoryError(null);
setNewCategoryError(null);
setNewFieldError(null);
setNewCategoryFields([]);
dynamicInitializedRef.current = false;
lastCategoryRef.current = null;
setPrefilledMetadata({});
},
[isEdit, item, categories],
);
const fetchCategories = useCallback(async () => {
setCategoryError(null);
if (categoriesCache.data) {
setCategoryTemplates(categoriesCache.data);
resetFormState(categoriesCache.data);
return;
}
if (categoriesCache.pending) {
setCategoryLoading(true);
try {
const cached = await categoriesCache.pending;
if (cached) {
setCategoryTemplates(cached);
resetFormState(cached);
}
} catch (error) {
console.error(error);
setCategoryError(
error instanceof Error
? error.message
: "Erro ao carregar categorias",
);
} finally {
setCategoryLoading(false);
}
return;
}
setCategoryLoading(true);
const pendingPromise = (async () => {
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();
const templates = Array.isArray(data?.categories)
? data.categories
: [];
categoriesCache.data = templates;
setCategoryTemplates(templates);
resetFormState(templates);
return templates;
} catch (error) {
console.error(error);
setCategoryError(
error instanceof Error
? error.message
: "Erro ao carregar categorias",
);
throw error;
} finally {
categoriesCache.pending = null;
setCategoryLoading(false);
}
})();
categoriesCache.pending = pendingPromise;
await pendingPromise;
}, [resetFormState]);
useEffect(() => {
if (!isActive) {
resetFormState(categoryTemplates);
return;
}
fetchCategories();
}, [isActive, fetchCategories, categoryTemplates, resetFormState]);
useEffect(() => {
if (!isActive) {
resetFormState(categoryTemplates);
return;
}
if (categoryTemplates.length === 0) {
resetFormState(categoryTemplates);
}
}, [isActive, categoryTemplates, resetFormState]);
const getTemplateForCategory = useCallback(
(categoryName: string | null | undefined) =>
categoryTemplates.find(
(template) => template.name === (categoryName ?? "").toLowerCase(),
),
[categoryTemplates],
);
const buildDynamicInitialValues = useCallback(
(
template: CategoryTemplate | undefined,
metadata?: Record<string, unknown>,
) => {
if (!template) return {} as Record<string, string | boolean | null>;
const values: Record<string, string | boolean | null> = {};
template.columns
.filter(
(column) =>
!BASE_COLUMN_NAMES.includes(column.name) &&
column.editable !== false,
)
.forEach((column) => {
const sourceValue = metadata ? metadata[column.name] : undefined;
if (sourceValue === undefined || sourceValue === null) {
values[column.name] = column.type === "BOOLEAN" ? false : "";
return;
}
if (column.type === "BOOLEAN") {
values[column.name] = Boolean(Number(sourceValue));
} else if (column.type === "INTEGER" || column.type === "REAL") {
values[column.name] = String(sourceValue);
} else {
values[column.name] = String(sourceValue);
}
});
return values;
},
[],
);
useEffect(() => {
if (!isActive) return;
const effectiveCategory = (
category ||
item?.category ||
categoryTemplates[0]?.name ||
""
).toLowerCase();
if (!effectiveCategory) return;
const template = getTemplateForCategory(effectiveCategory);
if (!template) return;
if (!category) {
setCategory(template.name);
}
const metadataSource = isEdit
? (item?.metadata as Record<string, unknown> | undefined)
: undefined;
if (
!dynamicInitializedRef.current ||
lastCategoryRef.current !== template.name
) {
setDynamicValues(buildDynamicInitialValues(template, metadataSource));
dynamicInitializedRef.current = true;
lastCategoryRef.current = template.name;
}
}, [
isActive,
category,
item,
categoryTemplates,
getTemplateForCategory,
buildDynamicInitialValues,
isEdit,
]);
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: originServer || (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);
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(
error instanceof Error
? error.message
: "Erro desconhecido ao gerar JSON.",
);
} finally {
setAnalysisLoading(false);
}
};
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());
markUnsaved("category");
},
[markUnsaved],
);
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");
}
categoriesCache.data = null;
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");
}
categoriesCache.data = null;
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],
);
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<keyof PrefillPayload> = [
"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<string, unknown>)) {
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, normalizeValue],
);
const markUnsavedFromDiff = useCallback(
(diff: { changed: DiffEntry[]; added: DiffEntry[] }) => {
const next = new Set<string>();
for (const d of [...diff.changed, ...diff.added]) {
next.add(d.field);
}
setUnsavedFields(next);
},
[],
);
const _renderUnsavedTag = (field: string) =>
unsavedFields.has(field) ? (
<span className="ml-2 text-[10px] text-amber-600">Não salvo</span>
) : 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<string, unknown> = { ...prefilledMetadata };
if (aiSuggestedName) {
metadataPayload.confirmar_nome = confirmNome;
}
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,
server: originServer,
});
setSubmitStatus(
isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso.",
);
onSuccess?.();
if (!isEdit) {
resetFormState(categoryTemplates);
}
} catch (error) {
console.error(error);
setSubmitStatus(
error instanceof Error
? error.message
: "Erro ao salvar. Tente novamente.",
);
}
}, [
name,
category,
rarity,
imageUrl,
imageLocalPath,
description,
level,
weight,
slots,
skill,
serversCsv,
tagsCsv,
dynamicValues,
allowAiTools,
analysis,
onSubmit,
item,
isEdit,
onSuccess,
categoryTemplates,
resetFormState,
prefilledMetadata,
aiSuggestedName,
confirmNome,
originServer,
]);
const title = isEdit ? "Editar item" : "Novo item";
const descriptionText = allowAiTools
? "Preencha os dados do item unificado. Utilize a IA para gerar um JSON de referência, se desejar."
: "Preencha os dados do item unificado. Ajuste as sugestões geradas pela IA acima para concluir o cadastro.";
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
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<string | null>(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 ?? "");
if (prefill.description !== undefined)
setDescription(prefill.description ?? "");
if (prefill.rarity !== undefined) setRarity(prefill.rarity ?? "");
if (prefill.imageUrl !== undefined) setImageUrl(prefill.imageUrl ?? "");
if (prefill.imageLocalPath !== undefined)
setImageLocalPath(prefill.imageLocalPath ?? "");
if (prefill.level !== undefined)
setLevel(prefill.level ? String(prefill.level) : "");
if (prefill.weight !== undefined)
setWeight(prefill.weight ? String(prefill.weight) : "");
if (prefill.slots !== undefined)
setSlots(prefill.slots ? String(prefill.slots) : "");
if (prefill.skill !== undefined) setSkill(prefill.skill ?? "");
if (prefill.servers !== undefined) {
const serversValue = Array.isArray(prefill.servers)
? prefill.servers.join(", ")
: (prefill.servers ?? "");
setServersCsv(serversValue);
}
if (prefill.tags !== undefined) {
const tagsValue = Array.isArray(prefill.tags)
? prefill.tags.join(", ")
: (prefill.tags ?? "");
setTagsCsv(tagsValue);
}
let targetCategory = category;
const metadataCategory =
typeof prefill.metadata?.category === "string"
? prefill.metadata.category.toLowerCase()
: null;
if (prefill.category) {
targetCategory = prefill.category.toLowerCase();
setCategory(targetCategory);
dynamicInitializedRef.current = false;
lastCategoryRef.current = null;
} else if (metadataCategory) {
targetCategory = metadataCategory;
setCategory(targetCategory);
dynamicInitializedRef.current = false;
lastCategoryRef.current = null;
}
if (prefill.metadata && typeof prefill.metadata === "object") {
setPrefilledMetadata(prefill.metadata);
const template = getTemplateForCategory(targetCategory);
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);
}
},
[buildDynamicInitialValues, category, getTemplateForCategory],
);
useEffect(() => {
if (prefillData) {
setPendingPrefill(prefillData);
}
}, [prefillData]);
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);
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 (
<>
<form
onSubmit={handleFormSubmit}
className={formClassName}
aria-busy={prefillLoading}
aria-live="polite"
>
{headerSlot?.({ title, description: descriptionText }) ?? (
<div className="shrink-0 px-6 pt-6">
<h2 className="text-lg font-semibold leading-none tracking-tight">
{title}
</h2>
<p className="text-sm text-muted-foreground">{descriptionText}</p>
</div>
)}
<div className={bodyClassName}>
<div className="grid min-w-0 gap-6 lg:grid-cols-[1.15fr,0.85fr]">
<div className="space-y-4 min-w-0">
<div className="space-y-2">
<Label htmlFor="item-name">
Nome{" "}
{isUnsaved("name") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-name"
placeholder="Ex.: Final Fate"
value={name}
onChange={(event) => {
setName(event.target.value);
setAiSuggestedName(false);
setConfirmNome(false);
markUnsaved("name");
}}
/>
{aiSuggestedName && (
<div className="flex items-center gap-2">
<Checkbox
id="confirm-ai-name"
checked={confirmNome}
onCheckedChange={(checked) => {
setConfirmNome(Boolean(checked));
markUnsaved("confirmar_nome");
}}
/>
<Label htmlFor="confirm-ai-name" className="text-xs">
Confirmar nome sugerido pela IA
</Label>
</div>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>
Categoria{" "}
{isUnsaved("category") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<div className="flex flex-wrap items-center gap-2">
<Select
value={category}
onValueChange={handleCategoryChange}
disabled={categoryLoading || isEdit}
>
<SelectTrigger className="min-w-[200px]">
<SelectValue
placeholder={
categoryLoading
? "Carregando..."
: "Selecione a categoria"
}
/>
</SelectTrigger>
<SelectContent>
{categoryTemplates.map((template) => (
<SelectItem key={template.name} value={template.name}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowNewCategoryModal(true);
setNewCategoryError(null);
setNewCategoryName("");
setNewCategoryFields([]);
}}
disabled={categoryLoading}
>
Nova categoria
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowNewFieldModal(true);
setNewFieldError(null);
setNewFieldName("");
setNewFieldType("TEXT");
setNewFieldNotNull(false);
}}
disabled={!category || categoryLoading}
>
+ campo
</Button>
</div>
{categoryError && (
<p className="text-xs text-destructive">{categoryError}</p>
)}
{isEdit && (
<p className="text-xs text-muted-foreground">
A categoria existente do item não pode ser alterada neste
fluxo.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="item-rarity">
Raridade{" "}
{isUnsaved("rarity") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-rarity"
placeholder="Ex.: legendary"
value={rarity}
onChange={(event) => {
setRarity(event.target.value);
markUnsaved("rarity");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-image-url">
Imagem (URL){" "}
{isUnsaved("imageUrl") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-image-url"
placeholder="https://..."
value={imageUrl}
onChange={(event) => {
setImageUrl(event.target.value);
markUnsaved("imageUrl");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-image-local">
Imagem local (caminho){" "}
{isUnsaved("imageLocalPath") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-image-local"
placeholder="Opcional"
value={imageLocalPath}
onChange={(event) => {
setImageLocalPath(event.target.value);
markUnsaved("imageLocalPath");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-level">
Level requerido{" "}
{isUnsaved("level") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-level"
placeholder="Ex.: 250"
value={level}
onChange={(event) => {
setLevel(event.target.value);
markUnsaved("level");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-weight">
Peso{" "}
{isUnsaved("weight") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-weight"
placeholder="Ex.: 45"
value={weight}
onChange={(event) => {
setWeight(event.target.value);
markUnsaved("weight");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-slots">
Slots{" "}
{isUnsaved("slots") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-slots"
placeholder="Ex.: 2"
value={slots}
onChange={(event) => {
setSlots(event.target.value);
markUnsaved("slots");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-skill">
Skill requerido{" "}
{isUnsaved("skill") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-skill"
placeholder="Ex.: Sword 115"
value={skill}
onChange={(event) => {
setSkill(event.target.value);
markUnsaved("skill");
}}
/>
</div>
</div>
{dynamicColumns.length > 0 && (
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">
Campos específicos da categoria{" "}
{isUnsaved("fields") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<p className="text-xs text-muted-foreground">
Defina valores adicionais conforme o modelo da categoria
selecionada.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{dynamicColumns.map((column) => {
const value = dynamicValues[column.name];
if (column.type === "BOOLEAN") {
return (
<div
key={column.name}
className="flex items-center gap-2"
>
<Checkbox
id={`dynamic-${column.name}`}
checked={Boolean(value)}
onCheckedChange={(checked) =>
handleDynamicValueChange(
column,
checked === true,
)
}
/>
<Label
htmlFor={`dynamic-${column.name}`}
className="capitalize"
>
{column.name.replace(/_/g, " ")}
{isUnsaved(`fields.${column.name}`) && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
</div>
);
}
const inputType =
column.type === "TEXT" ? "text" : "number";
const step = column.type === "REAL" ? "0.01" : "1";
return (
<div key={column.name} className="space-y-2">
<Label
htmlFor={`dynamic-${column.name}`}
className="capitalize"
>
{column.name.replace(/_/g, " ")}
{isUnsaved(`fields.${column.name}`) && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id={`dynamic-${column.name}`}
type={inputType}
step={column.type === "TEXT" ? undefined : step}
value={
typeof value === "string"
? value
: value === null || value === undefined
? ""
: String(value)
}
onChange={(event) => {
handleDynamicValueChange(
column,
event.target.value,
);
markUnsaved(`fields.${column.name}`);
}}
/>
</div>
);
})}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="origin-server">
Servidor de origem{" "}
{isUnsaved("server") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Select
value={originServer}
onValueChange={(value) => {
setOriginServer(value);
markUnsaved("server");
}}
>
<SelectTrigger id="origin-server" className="w-full">
<SelectValue placeholder="Selecione um servidor" />
</SelectTrigger>
<SelectContent>
{serverOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
{!originServer && (
<p className="text-[11px] text-destructive">
Campo obrigatório. Selecione o servidor de origem.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="item-servers">
Servidores (separados por vírgula){" "}
{isUnsaved("servers") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-servers"
placeholder="Ex.: Ferobra, Boneka"
value={serversCsv}
onChange={(event) => {
setServersCsv(event.target.value);
markUnsaved("servers");
}}
/>
{serverSuggestions && (
<p className="text-[11px] text-muted-foreground">
Sugestões cadastradas: {serverSuggestions}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="item-tags">
Tags adicionais{" "}
{isUnsaved("tags") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Input
id="item-tags"
placeholder="Ex.: endgame, two-handed"
value={tagsCsv}
onChange={(event) => {
setTagsCsv(event.target.value);
markUnsaved("tags");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-description">
Descrição detalhada{" "}
{isUnsaved("description") && (
<Badge
variant="outline"
className="ml-2 text-amber-600 border-amber-500"
>
Não salvo
</Badge>
)}
</Label>
<Textarea
id="item-description"
placeholder="Cole aqui a descrição oficial do item..."
rows={6}
value={description}
onChange={(event) => {
setDescription(event.target.value);
markUnsaved("description");
}}
/>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{allowAiTools && (
<Button
type="button"
variant="outline"
onClick={handleAnalyze}
disabled={analysisLoading}
>
{analysisLoading ? "Analisando..." : "Analisar com IA"}
</Button>
)}
{allowAiTools && (
<Button
type="button"
variant="secondary"
onClick={async () => {
try {
setPrefillStatus(null);
if (!analysis) {
setPrefillStatus(
"Gere o JSON com IA antes de preencher automaticamente.",
);
return;
}
setPrefillLoading(true);
// Construir prefill a partir de analysis { table, columns }
const columns = (analysis as any)?.columns ?? {};
const table = (analysis as any)?.table ?? null;
const pick = (obj: any, keyList: string[]): any => {
for (const k of keyList) {
if (obj && obj[k] !== undefined && obj[k] !== null)
return obj[k];
}
return undefined;
};
const topName = pick(columns, [
"nome",
"name",
"item_name",
]);
const imageUrlPref = pick(columns, [
"image_url",
"url",
"imagem",
]);
const imageLocalPref = pick(columns, [
"image_local_path",
"local_path",
]);
const rarityPref = pick(columns, [
"rarity",
"raridade",
]);
const levelPref = pick(columns, ["level", "nivel"]);
const weightPref = pick(columns, ["weight", "peso"]);
const slotsPref = pick(columns, ["slots"]);
const skillPref = pick(columns, [
"skill",
"habilidade",
]);
const descriptionPref = pick(columns, [
"description",
"descricao",
]);
const serversPref = pick(columns, [
"servers",
"servidores",
]);
const tagsPref = pick(columns, ["tags", "etiquetas"]);
// Montar metadata com demais chaves
const baseKeys = new Set([
"nome",
"name",
"item_name",
"image_url",
"url",
"imagem",
"image_local_path",
"local_path",
"rarity",
"raridade",
"level",
"nivel",
"weight",
"peso",
"slots",
"skill",
"habilidade",
"description",
"descricao",
"servers",
"servidores",
"tags",
"etiquetas",
"category",
"categoria",
]);
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(columns)) {
if (!baseKeys.has(k)) {
metadata[k] = v as unknown;
}
}
if (table && typeof table === "string") {
metadata.category = table.toLowerCase();
}
const prefill: PrefillPayload = {
name:
topName !== undefined ? String(topName) : undefined,
category: table
? String(table).toLowerCase()
: undefined,
rarity:
rarityPref !== undefined
? String(rarityPref)
: undefined,
imageUrl:
imageUrlPref !== undefined
? String(imageUrlPref)
: undefined,
imageLocalPath:
imageLocalPref !== undefined
? String(imageLocalPref)
: undefined,
level:
levelPref !== undefined
? typeof levelPref === "number"
? levelPref
: String(levelPref)
: undefined,
weight:
weightPref !== undefined
? typeof weightPref === "number"
? weightPref
: String(weightPref)
: undefined,
slots:
slotsPref !== undefined
? typeof slotsPref === "number"
? slotsPref
: String(slotsPref)
: undefined,
skill:
skillPref !== undefined
? String(skillPref)
: undefined,
description:
descriptionPref !== undefined
? String(descriptionPref)
: undefined,
servers:
serversPref !== undefined
? Array.isArray(serversPref)
? serversPref
: String(serversPref)
: undefined,
tags:
tagsPref !== undefined
? Array.isArray(tagsPref)
? tagsPref
: String(tagsPref)
: undefined,
metadata,
};
// Aplicar prefill e marcar campos alterados
const diff = computePrefillDiff(prefill);
setPrefillAnimating(true);
applyPrefill(prefill);
markUnsavedFromDiff(diff);
setPrefillStatus(
"Campos preenchidos automaticamente com base no JSON.",
);
setTimeout(() => setPrefillAnimating(false), 600);
} catch (e) {
console.error(e);
setPrefillStatus(
e instanceof Error
? e.message
: "Falha ao preencher automaticamente.",
);
} finally {
setPrefillLoading(false);
}
}}
disabled={analysisLoading || prefillLoading}
aria-label="Preencher automaticamente a partir do JSON"
aria-disabled={analysisLoading || prefillLoading}
>
{prefillLoading
? "Preenchendo..."
: "Preencher Automaticamente"}
</Button>
)}
<Button type="submit" disabled={submitting}>
{submitting ? "Salvando..." : submitLabel}
</Button>
</div>
{submitStatus && (
<p className="text-xs text-muted-foreground">{submitStatus}</p>
)}
{prefillStatus && (
<p className="text-xs text-muted-foreground">{prefillStatus}</p>
)}
</div>
<div className="space-y-4 min-w-0">
{allowAiTools && (
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-sm">Resultado da IA</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
O serviço Gemini deve retornar exclusivamente um JSON
válido que descreve a tabela destino e os campos
correspondentes.
</p>
{analysisError && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{analysisError}
</div>
)}
{tokenUsage && (
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className="px-2 py-1">
Tokens: in {tokenUsage.promptTokens} · out{" "}
{tokenUsage.outputTokens} · total{" "}
{tokenUsage.totalTokens}
</Badge>
<Badge variant="outline" className="px-2 py-1">
Custo (req): US$ {tokenUsage.costUsd.toFixed(6)}
</Badge>
<Badge variant="outline" className="px-2 py-1">
Acumulado (sessão): US${" "}
{accumulatedCostUsd.toFixed(6)}
</Badge>
</div>
)}
<div
className={`max-w-full w-full rounded-md border bg-muted/40 p-0 ${prefillAnimating ? "animate-pulse" : ""}`}
role="region"
aria-label="Pré-visualização do JSON gerado"
>
<pre
className="whitespace-pre overflow-x-auto max-w-full w-full min-w-0 p-4 font-mono rounded-md shadow-sm bg-muted text-sm"
aria-live="polite"
>
{formattedAnalysis
? JSON.stringify(formattedAnalysis, null, 2)
: "// Aguarde a análise para visualizar o JSON"}
</pre>
{formattedAnalysis &&
(formattedAnalysis as any)?.schemaSuggestion && (
<div className="p-3 border-t bg-background">
<p className="text-xs text-muted-foreground mb-2">
Sugestão de esquema (SQLite)
</p>
<div className="flex flex-wrap items-center gap-2 mb-2">
<Badge
variant="outline"
className="px-2 py-1 uppercase"
>
{(formattedAnalysis as any).schemaSuggestion
.table || "tabela"}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
{Array.isArray(
(formattedAnalysis as any).schemaSuggestion
.columns,
) ? (
<ul className="list-disc ml-5">
{(
formattedAnalysis as any
).schemaSuggestion.columns.map(
(col: any, idx: number) => (
<li key={idx}>
{String(col?.name ?? "coluna")} :{" "}
{String(col?.type ?? "TEXT")}
</li>
),
)}
</ul>
) : (
<p>Sem colunas sugeridas.</p>
)}
</div>
</div>
)}
<div className="p-3 border-t bg-background flex items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={async () => {
try {
setPrefillStatus(null);
if (!analysis) {
setPrefillStatus(
"Gere o JSON com IA antes de preencher automaticamente.",
);
return;
}
setPrefillLoading(true);
const columns = (analysis as any)?.columns ?? {};
const table = (analysis as any)?.table ?? null;
const pick = (
obj: any,
keyList: string[],
): any => {
for (const k of keyList) {
if (
obj &&
obj[k] !== undefined &&
obj[k] !== null
)
return obj[k];
}
return undefined;
};
const topName = pick(columns, [
"nome",
"name",
"item_name",
]);
const imageUrlPref = pick(columns, [
"image_url",
"url",
"imagem",
]);
const imageLocalPref = pick(columns, [
"image_local_path",
"local_path",
]);
const rarityPref = pick(columns, [
"rarity",
"raridade",
]);
const levelPref = pick(columns, [
"level",
"nivel",
]);
const weightPref = pick(columns, [
"weight",
"peso",
]);
const slotsPref = pick(columns, ["slots"]);
const skillPref = pick(columns, [
"skill",
"habilidade",
]);
const descriptionPref = pick(columns, [
"description",
"descricao",
]);
const serversPref = pick(columns, [
"servers",
"servidores",
]);
const tagsPref = pick(columns, [
"tags",
"etiquetas",
]);
const baseKeys = new Set([
"nome",
"name",
"item_name",
"image_url",
"url",
"imagem",
"image_local_path",
"local_path",
"rarity",
"raridade",
"level",
"nivel",
"weight",
"peso",
"slots",
"skill",
"habilidade",
"description",
"descricao",
"servers",
"servidores",
"tags",
"etiquetas",
"category",
"categoria",
]);
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(columns)) {
if (!baseKeys.has(k)) {
metadata[k] = v as unknown;
}
}
if (table && typeof table === "string") {
metadata.category = table.toLowerCase();
}
const prefill: PrefillPayload = {
name:
topName !== undefined
? String(topName)
: undefined,
category: table
? String(table).toLowerCase()
: undefined,
rarity:
rarityPref !== undefined
? String(rarityPref)
: undefined,
imageUrl:
imageUrlPref !== undefined
? String(imageUrlPref)
: undefined,
imageLocalPath:
imageLocalPref !== undefined
? String(imageLocalPref)
: undefined,
level:
levelPref !== undefined
? typeof levelPref === "number"
? levelPref
: String(levelPref)
: undefined,
weight:
weightPref !== undefined
? typeof weightPref === "number"
? weightPref
: String(weightPref)
: undefined,
slots:
slotsPref !== undefined
? typeof slotsPref === "number"
? slotsPref
: String(slotsPref)
: undefined,
skill:
skillPref !== undefined
? String(skillPref)
: undefined,
description:
descriptionPref !== undefined
? String(descriptionPref)
: undefined,
servers:
serversPref !== undefined
? Array.isArray(serversPref)
? serversPref
: String(serversPref)
: undefined,
tags:
tagsPref !== undefined
? Array.isArray(tagsPref)
? tagsPref
: String(tagsPref)
: undefined,
metadata,
};
const diff = computePrefillDiff(prefill);
setPrefillAnimating(true);
applyPrefill(prefill);
markUnsavedFromDiff(diff);
setPrefillStatus(
"Campos preenchidos automaticamente com base no JSON.",
);
setTimeout(() => setPrefillAnimating(false), 600);
} catch (e) {
console.error(e);
setPrefillStatus(
e instanceof Error
? e.message
: "Falha ao preencher automaticamente.",
);
} finally {
setPrefillLoading(false);
}
}}
disabled={analysisLoading || prefillLoading}
aria-label="Preencher automaticamente a partir do JSON"
aria-disabled={analysisLoading || prefillLoading}
>
{prefillLoading
? "Preenchendo..."
: "Preencher Automaticamente"}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-sm">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="uppercase">
{category || "Categoria"}
</Badge>
{rarity && (
<Badge variant="outline" className="uppercase">
{rarity}
</Badge>
)}
</div>
<p>
Utilize os campos para definir servidores específicos. Todos
os itens sem servidores marcados serão considerados globais.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
{footerSlot?.({
isEdit,
submitting,
submitLabel,
onCancel: handleCancelClick,
onSubmitClick: handlePrimarySubmit,
}) ?? (
<div className="flex items-center justify-end gap-2 px-6 pb-6">
<Button
type="button"
variant="outline"
onClick={handleCancelClick}
disabled={submitting}
>
Cancelar
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Salvando..." : submitLabel}
</Button>
</div>
)}
</form>
<Dialog open={showExitConfirm} onOpenChange={setShowExitConfirm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Descartar alterações?</DialogTitle>
<DialogDescription>
Existem alterações não salvas. Deseja sair sem salvar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowExitConfirm(false)}>
Continuar editando
</Button>
<Button
variant="destructive"
onClick={() => {
setShowExitConfirm(false);
onCancel?.();
}}
>
Descartar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={showNewCategoryModal}
onOpenChange={setShowNewCategoryModal}
>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Nova categoria</DialogTitle>
<DialogDescription>
Defina nome e campos adicionais para criar uma nova categoria de
itens.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-category-name">Nome da categoria</Label>
<Input
id="new-category-name"
placeholder="Ex.: vips"
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Campos iniciais (opcional)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCategoryField}
>
Adicionar campo
</Button>
</div>
{newCategoryFields.length === 0 ? (
<p className="text-xs text-muted-foreground">
Nenhum campo adicional configurado.
</p>
) : (
<div className="space-y-3">
{newCategoryFields.map((field) => (
<div
key={field.id}
className="grid gap-2 rounded-md border p-3"
>
<div className="space-y-1">
<Label htmlFor={`field-name-${field.id}`}>Nome</Label>
<Input
id={`field-name-${field.id}`}
value={field.name}
onChange={(event) =>
handleUpdateCategoryField(
field.id,
"name",
event.target.value,
)
}
placeholder="Ex.: bonus_magico"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label>Tipo</Label>
<Select
value={field.type}
onValueChange={(value) =>
handleUpdateCategoryField(field.id, "type", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">Texto</SelectItem>
<SelectItem value="INTEGER">Inteiro</SelectItem>
<SelectItem value="REAL">Decimal</SelectItem>
<SelectItem value="BOOLEAN">Booleano</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`field-required-${field.id}`}
checked={field.notNull}
onCheckedChange={(checked) =>
handleUpdateCategoryField(
field.id,
"notNull",
checked === true,
)
}
/>
<Label htmlFor={`field-required-${field.id}`}>
Obrigatório
</Label>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveCategoryField(field.id)}
>
Remover
</Button>
</div>
</div>
))}
</div>
)}
</div>
{newCategoryError && (
<p className="text-xs text-destructive">{newCategoryError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewCategoryModal(false)}
>
Cancelar
</Button>
<Button onClick={handleCreateCategory}>Criar categoria</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewFieldModal} onOpenChange={setShowNewFieldModal}>
<DialogContent className="max-w-md max-h-[70vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Novo campo para categoria</DialogTitle>
<DialogDescription>
Adicione um novo campo à categoria selecionada. A alteração é
permanente no schema.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Categoria</Label>
<Input value={category} readOnly className="bg-muted/40" />
</div>
<div className="space-y-2">
<Label htmlFor="new-field-name">Nome do campo</Label>
<Input
id="new-field-name"
value={newFieldName}
onChange={(event) => setNewFieldName(event.target.value)}
placeholder="Ex.: bonus_magico"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label>Tipo</Label>
<Select
value={newFieldType}
onValueChange={(value) =>
setNewFieldType(value as CategoryColumn["type"])
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">Texto</SelectItem>
<SelectItem value="INTEGER">Inteiro</SelectItem>
<SelectItem value="REAL">Decimal</SelectItem>
<SelectItem value="BOOLEAN">Booleano</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="new-field-required"
checked={newFieldNotNull}
onCheckedChange={(checked) =>
setNewFieldNotNull(checked === true)
}
/>
<Label htmlFor="new-field-required">Obrigatório</Label>
</div>
</div>
{newFieldError && (
<p className="text-xs text-destructive">{newFieldError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFieldModal(false)}
>
Cancelar
</Button>
<Button onClick={handleAddFieldToCategory}>Adicionar campo</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}