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:
Evandro Fonseca Junior committed 2025-11-11 03:04:49 -03:00
1 parent dc169f9eba
commit d3704602fd
22 files changed
+1317 -95

No files matched your search

@@ -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", {
@@ -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", {
+609 -43
View File
@@ -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>
+122 -16
View File
@@ -55,6 +55,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const [data, setData] = useState<AdminItemsResponse>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiStatus, setApiStatus] = useState<{ code: number | null; ok: boolean | null; durationMs: number | null; checkedAt: string | null }>({ code: null, ok: null, durationMs: null, checkedAt: null });
const [serverFilter, setServerFilter] = useState<string>("all");
const [search, setSearch] = useState<string>("");
@@ -66,33 +67,132 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const [modalOpen, setModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined);
function isAdminItemsResponse(val: any): val is AdminItemsResponse {
return (
val &&
Array.isArray(val.items) &&
typeof val.total === "number" &&
typeof val.page === "number" &&
typeof val.pageSize === "number" &&
Array.isArray(val.servers) &&
Array.isArray(val.categories)
);
}
// Carrega servidores ativos independentemente dos itens cadastrados
useEffect(() => {
let cancelled = false;
async function loadServers() {
try {
setServersLoading(true);
const res = await fetch("/api/servers", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
return;
}
const json = await res.json();
const names: string[] = Array.isArray(json?.servers)
? json.servers
.map((s: any) => (typeof s?.serverName === "string" ? s.serverName : null))
.filter((n: string | null): n is string => Boolean(n))
: [];
if (!cancelled) {
setActiveServers(names);
}
} catch (e) {
console.warn("[AdminItemManager] erro ao buscar /api/servers:", e);
} finally {
if (!cancelled) setServersLoading(false);
}
}
loadServers();
return () => {
cancelled = true;
};
}, []);
const fetchItems = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
if (serverFilter !== "all") params.set("server", serverFilter);
if (categoryFilter !== "all") params.set("category", categoryFilter);
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
params.set("page", String(page));
params.set("pageSize", String(pageSize));
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
if (serverFilter !== "all") params.set("server", serverFilter);
if (categoryFilter !== "all") params.set("category", categoryFilter);
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
params.set("page", String(page));
params.set("pageSize", String(pageSize));
const res = await fetch(`/api/admin/items?${params.toString()}`, {
const url = `/api/admin/items?${params.toString()}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 15000);
const start = performance.now();
console.group("[AdminItemManager] fetchItems");
console.log("URL:", url);
console.log("QueryParams:", Object.fromEntries(params.entries()));
try {
const res = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - start);
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() });
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`);
console.log("Headers:", {
"content-type": res.headers.get("content-type"),
"x-powered-by": res.headers.get("x-powered-by"),
});
if (!res.ok) {
throw new Error("Não foi possível carregar os itens.");
let errorPayload: any = null;
try {
errorPayload = await res.json();
} catch {
// pode não ser JSON
}
const baseMsg =
res.status === 401
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
: res.status === 403
? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens.";
const apiMsg = errorPayload?.error || errorPayload?.message || null;
const composed = apiMsg ? `${baseMsg} (${apiMsg})` : `${baseMsg} (HTTP ${res.status})`;
console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed);
}
const json = await res.json();
if (!isAdminItemsResponse(json)) {
console.error("Resposta inválida da API:", json);
throw new Error("Resposta inválida da API de itens.");
}
const json = (await res.json()) as AdminItemsResponse;
setData(json);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
} catch (err: any) {
const durationMs = Math.round(performance.now() - start);
setApiStatus((prev) => ({ code: prev.code, ok: false, durationMs, checkedAt: new Date().toISOString() }));
if (err?.name === "AbortError") {
console.error("Requisição abortada por timeout");
setError("Tempo de resposta excedido. Tente novamente.");
} else {
console.error("Falha ao carregar itens:", err);
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
}
} finally {
console.groupEnd();
window.clearTimeout(timeoutId);
setLoading(false);
}
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
@@ -235,8 +335,14 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
))}
</SelectContent>
</Select>
<div className="text-sm text-muted-foreground flex items-center">
<div className="text-sm text-muted-foreground flex items-center gap-2">
{loading ? "Carregando..." : `${data.total} itens encontrados`}
{apiStatus.code !== null && (
<Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
</Badge>
)}
</div>
</div>
</CardContent>
@@ -397,7 +503,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
mode="edit"
item={selectedItem ?? undefined}
categories={data.categories}
serverOptions={data.servers}
serverOptions={activeServers.length ? activeServers : data.servers}
onSubmit={handleSubmit}
submitting={isSubmitting}
/>
+80 -7
View File
@@ -40,15 +40,28 @@ export function ItemAiAssistant({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJson, setRawJson] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const disabled = useMemo(() => !name.trim() || !description.trim(), [name, description]);
// Habilita geração se houver ao menos uma imagem válida OU nome+descrição preenchidos
const canGenerate = useMemo(() => {
const hasValidImage = images.length > 0;
const hasTextInputs = name.trim() && description.trim();
return Boolean(hasValidImage || hasTextInputs);
}, [images.length, name, description]);
const disabled = useMemo(() => !canGenerate, [canGenerate]);
const handleGenerate = useCallback(async () => {
if (disabled || loading) return;
setLoading(true);
setError(null);
setLogs((prev) => [...prev, "Iniciando geração com IA..."]);
try {
setLogs((prev) => [
...prev,
`Payload: nome='${name.trim()}', escopo='${scope}', imagens=${images.length}, servidor='${scope === "server" ? server : "(global)"}'`,
]);
const response = await fetch("/api/admin/items/analyze", {
method: "POST",
credentials: "include",
@@ -73,13 +86,57 @@ export function ItemAiAssistant({
const data = await response.json();
const structure = data?.structure ?? data;
const columns = structure?.columns;
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
setRawJson(JSON.stringify(structure, null, 2));
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
if (!Array.isArray(imgs) || imgs.length === 0) return [];
const first = imgs[0];
const base = first.originalName
.replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase()
.replace(/[_\s]+/g, "-");
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
const orientation = first.height > first.width ? "retrato" : "paisagem";
const suggestions: string[] = [];
if (base && !common.has(base)) suggestions.push(base);
suggestions.push(`${orientation}-1`);
suggestions.push("foto-1");
return Array.from(new Set(suggestions)).slice(0, 3);
};
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
// Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = {
...structure,
columns: {
...columns,
origem_dados: origemDados,
confirmar_nome: autoNameDetected ? false : undefined,
mensagens: {
obrigatorio: ["nome"],
dicas: autoNameDetected
? "Confirme o nome sugerido ou ajuste se necessário."
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
},
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
},
};
setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = {
name: typeof columns?.name === "string" ? columns.name : undefined,
description: typeof columns?.description === "string" ? columns.description : undefined,
metadata: typeof columns === "object" && columns !== null ? columns : undefined,
name: autoNameDetected ? (columns!.name as string) : undefined,
description: autoDescDetected ? (columns!.description as string) : undefined,
metadata: typeof columns === "object" && columns !== null
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
};
if (columns) {
@@ -103,12 +160,15 @@ export function ItemAiAssistant({
}
}
onGenerated({ json: structure, prefill });
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
onGenerated({ json: finalStructure, prefill });
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
} finally {
setLoading(false);
setLogs((prev) => [...prev, "Processo de geração concluído."]);
}
}, [
categories,
@@ -199,6 +259,9 @@ export function ItemAiAssistant({
<div className="space-y-2">
<label className="text-sm font-medium">Prints / evidências visuais</label>
<ItemImageCapture onImagesChange={setImages} />
{images.length > 0 && (
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
)}
</div>
{error && (
@@ -213,7 +276,7 @@ export function ItemAiAssistant({
Gerar com IA
</Button>
<p className="text-xs text-muted-foreground">
A IA retornará um JSON com sugestões de campos. Eles serão aplicados automaticamente no formulário.
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
</p>
</div>
</div>
@@ -233,6 +296,16 @@ export function ItemAiAssistant({
campos do formulário.
</p>
)}
{logs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
<div className="rounded-md border bg-muted/30 p-2">
{logs.map((line, idx) => (
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
@@ -202,6 +202,10 @@ export function ItemImageCapture({
>
<X className="h-4 w-4" />
</button>
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-emerald-600/90 px-2 py-0.5 text-[11px] text-white">
<span className="h-1.5 w-1.5 rounded-full bg-white" />
Pronta
</span>
<img
src={image.previewUrl}
alt={image.originalName}
+6 -4
View File
@@ -5,7 +5,7 @@ import { MagicCard } from "@/components/ui/magic-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/ui/price-display";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import {
TrendingUp,
TrendingDown,
@@ -39,6 +39,7 @@ import {
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
import { formatNumber } from "@/lib/utils/formatters";
interface ItemCardProps {
item: TibiaItem;
@@ -47,6 +48,7 @@ interface ItemCardProps {
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item");
const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => {
@@ -659,21 +661,21 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.highest, locale)} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span>
</div>
<div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.lowest, locale)} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span>
</div>
<div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.average, locale)} gp</div>
</div>
</div>
</div>