feat(items): add server field and improve item management
refactor(ai): enhance item structure generation with schema validation feat(ai): add token usage tracking and cost calculation fix(ui): improve item card number formatting with locale support docs(items): add documentation for item value separation test(items): add tests for value field validation
This commit is contained in:
1 parent
dc169f9eba
commit
d3704602fd
22 files changed
+1317
-95
No files matched your search
@@ -67,6 +67,7 @@ export interface ItemFormPayload {
|
||||
tags: string[];
|
||||
metadata?: Record<string, any>;
|
||||
fields?: Record<string, string | boolean | null>;
|
||||
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<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[]>([]);
|
||||
@@ -216,6 +229,27 @@ export function AdminItemForm({
|
||||
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[]) => {
|
||||
@@ -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<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]);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<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 ?? "");
|
||||
@@ -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 (
|
||||
<>
|
||||
<form onSubmit={handleFormSubmit} className={formClassName}>
|
||||
<form onSubmit={handleFormSubmit} className={formClassName} aria-busy={prefillLoading} aria-live="polite">
|
||||
{headerSlot?.({ title, description: descriptionText }) ??
|
||||
(
|
||||
<div className="shrink-0 px-6 pt-6">
|
||||
@@ -754,18 +979,42 @@ export function AdminItemForm({
|
||||
<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</Label>
|
||||
<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)}
|
||||
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</Label>
|
||||
<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}
|
||||
@@ -823,75 +1072,110 @@ export function AdminItemForm({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-rarity">Raridade</Label>
|
||||
<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)}
|
||||
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)</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setImageUrl(event.target.value);
|
||||
markUnsaved("imageUrl");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-image-local">Imagem local (caminho)</Label>
|
||||
<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)}
|
||||
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</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setLevel(event.target.value);
|
||||
markUnsaved("level");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-weight">Peso</Label>
|
||||
<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)}
|
||||
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</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setSlots(event.target.value);
|
||||
markUnsaved("slots");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-skill">Skill requerido</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setSkill(event.target.value);
|
||||
markUnsaved("skill");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -899,7 +1183,11 @@ export function AdminItemForm({
|
||||
{dynamicColumns.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Campos específicos da categoria</Label>
|
||||
<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>
|
||||
@@ -918,7 +1206,9 @@ export function AdminItemForm({
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
{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>
|
||||
);
|
||||
@@ -930,7 +1220,9 @@ export function AdminItemForm({
|
||||
return (
|
||||
<div key={column.name} className="space-y-2">
|
||||
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
|
||||
{column.name.replace(/_/g, " ")}
|
||||
{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}`}
|
||||
@@ -943,9 +1235,10 @@ export function AdminItemForm({
|
||||
? ""
|
||||
: String(value)
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleDynamicValueChange(column, event.target.value)
|
||||
}
|
||||
onChange={(event) => {
|
||||
handleDynamicValueChange(column, event.target.value);
|
||||
markUnsaved(`fields.${column.name}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -955,12 +1248,42 @@ export function AdminItemForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-servers">Servidores (separados por vírgula)</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setServersCsv(event.target.value);
|
||||
markUnsaved("servers");
|
||||
}}
|
||||
/>
|
||||
{serverSuggestions && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
@@ -970,24 +1293,34 @@ export function AdminItemForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-tags">Tags adicionais</Label>
|
||||
<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)}
|
||||
onChange={(event) => {
|
||||
setTagsCsv(event.target.value);
|
||||
markUnsaved("tags");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="item-description">Descrição detalhada</Label>
|
||||
<Textarea
|
||||
id="item-description"
|
||||
placeholder="Cole aqui a descrição oficial do item..."
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
<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">
|
||||
@@ -996,12 +1329,100 @@ export function AdminItemForm({
|
||||
{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">
|
||||
@@ -1019,10 +1440,130 @@ export function AdminItemForm({
|
||||
{analysisError}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px]">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{analysis ? JSON.stringify(analysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
|
||||
{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>
|
||||
@@ -1056,11 +1597,11 @@ export function AdminItemForm({
|
||||
isEdit,
|
||||
submitting,
|
||||
submitLabel,
|
||||
onCancel,
|
||||
onCancel: handleCancelClick,
|
||||
onSubmitClick: handlePrimarySubmit,
|
||||
}) ?? (
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
|
||||
<Button type="button" variant="outline" onClick={handleCancelClick} disabled={submitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
@@ -1070,6 +1611,31 @@ export function AdminItemForm({
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user