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