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
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user