style: clean up code formatting and remove trailing whitespace

refactor: reorganize imports and fix type imports
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-11 03:08:36 -03:00
1 parent d3704602fd
commit 47c04de6fe
232 files changed
+7140 -4798

No files matched your search

+25 -15
View File
@@ -1,10 +1,13 @@
"use client";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
import { AdminItemManager, type AdminItemsResponse } from "@/components/admin/items/AdminItemManager";
import {
AdminItemManager,
type AdminItemsResponse,
} from "@/components/admin/items/AdminItemManager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface AdminPanelTabsProps {
adminUser: {
@@ -28,9 +31,9 @@ export function AdminPanelTabs({
locale,
initialTab = "overview",
}: AdminPanelTabsProps) {
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
initialTab
);
const [activeTab, setActiveTab] = useState<
"overview" | "subscriptions" | "items"
>(initialTab);
return (
<Tabs
@@ -40,31 +43,39 @@ export function AdminPanelTabs({
>
<TabsList>
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
<TabsTrigger value="subscriptions">Assinaturas & Permissões</TabsTrigger>
<TabsTrigger value="subscriptions">
Assinaturas & Permissões
</TabsTrigger>
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle>
<CardTitle>
Bem-vindo, {adminUser?.username ?? "Administrador"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Utilize as abas acima para administrar planos de assinatura, permissões
e a curadoria de itens do marketplace. Esta área é exclusiva para
operadores autorizados.
Utilize as abas acima para administrar planos de assinatura,
permissões e a curadoria de itens do marketplace. Esta área é
exclusiva para operadores autorizados.
</p>
<p>
Para solicitações críticas, lembre-se de registrar mudanças e validar
com a equipe responsável antes de disponibilizar aos usuários finais.
Para solicitações críticas, lembre-se de registrar mudanças e
validar com a equipe responsável antes de disponibilizar aos
usuários finais.
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="subscriptions">
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} />
<AdminSubscriptionTab
initialTiers={initialTiers}
adminUser={adminUser}
/>
</TabsContent>
<TabsContent value="items">
@@ -73,4 +84,3 @@ export function AdminPanelTabs({
</Tabs>
);
}
+2 -3
View File
@@ -27,8 +27,8 @@ export function AdminSubscriptionTab({
</h2>
<p className="text-sm text-muted-foreground max-w-2xl">
Configure planos, recursos e vincule usuários às assinaturas
disponíveis. Todas as alterações são aplicadas imediatamente
após salvar.
disponíveis. Todas as alterações são aplicadas imediatamente após
salvar.
</p>
{adminUser?.subscription_tier && (
<p className="text-xs text-muted-foreground">
@@ -47,4 +47,3 @@ export function AdminSubscriptionTab({
</div>
);
}
+18 -8
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
@@ -13,7 +13,11 @@ interface AdminItemCreatePageProps {
serverOptions: string[];
}
export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) {
export function AdminItemCreatePage({
locale,
categories,
serverOptions,
}: AdminItemCreatePageProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -54,14 +58,20 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
});
if (!response.ok) {
const data = await response.json().catch(() => ({ error: "Erro ao criar item" }));
const data = await response
.json()
.catch(() => ({ error: "Erro ao criar item" }));
throw new Error(data?.error || data?.message || "Falha ao criar item");
}
goBackToList();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao criar item. Tente novamente.");
setError(
err instanceof Error
? err.message
: "Erro ao criar item. Tente novamente.",
);
} finally {
setSubmitting(false);
}
@@ -73,8 +83,9 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
<p className="text-muted-foreground max-w-2xl">
Utilize a seção de IA abaixo para gerar automaticamente campos sugeridos a partir de prints e descrições.
Ajuste o que for necessário antes de publicar.
Utilize a seção de IA abaixo para gerar automaticamente campos
sugeridos a partir de prints e descrições. Ajuste o que for
necessário antes de publicar.
</p>
</div>
<Button variant="outline" onClick={goBackToList} className="gap-2">
@@ -120,4 +131,3 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
interface AdminItemCreateViewProps {
@@ -10,7 +10,11 @@ interface AdminItemCreateViewProps {
serverOptions: string[];
}
export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) {
export function AdminItemCreateView({
locale,
categories,
serverOptions,
}: AdminItemCreateViewProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
@@ -43,7 +47,9 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Erro ao criar item" }));
const errorPayload = await response
.json()
.catch(() => ({ error: "Erro ao criar item" }));
throw new Error(errorPayload?.error || "Falha ao criar item");
}
@@ -75,4 +81,3 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
</div>
);
}
File diff suppressed because it is too large. Load diff
+197 -58
View File
@@ -1,19 +1,34 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RefreshCw, Plus, Pencil } from "lucide-react";
import Image from "next/image";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { ItemCreationModal, type ItemFormPayload } from "@/components/admin/items/ItemCreationModal";
import { Pencil, Plus, RefreshCw } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ItemCreationModal,
type ItemFormPayload,
} from "@/components/admin/items/ItemCreationModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface AdminItemRecord {
item_id: number;
@@ -50,12 +65,20 @@ interface AdminItemManagerProps {
locale: string;
}
export function AdminItemManager({ initialData, locale }: AdminItemManagerProps) {
export function AdminItemManager({
initialData,
locale,
}: AdminItemManagerProps) {
const router = useRouter();
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 [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>("");
@@ -65,10 +88,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const pageSize = initialData.pageSize ?? 50;
const [modalOpen, setModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false);
const [_serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined);
@@ -96,13 +121,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
cache: "no-store",
});
if (!res.ok) {
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
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))
.map((s: any) =>
typeof s?.serverName === "string" ? s.serverName : null,
)
.filter((n: string | null): n is string => Boolean(n))
: [];
if (!cancelled) {
@@ -147,8 +177,19 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
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`);
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"),
@@ -165,10 +206,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
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.";
? "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})`;
const composed = apiMsg
? `${baseMsg} (${apiMsg})`
: `${baseMsg} (HTTP ${res.status})`;
console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed);
}
@@ -179,23 +222,42 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
throw new Error("Resposta inválida da API de itens.");
}
setData(json);
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
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() }));
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.");
setError(
err instanceof Error ? err.message : "Falha ao carregar itens.",
);
}
} finally {
console.groupEnd();
window.clearTimeout(timeoutId);
setLoading(false);
}
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
}, [
search,
serverFilter,
categoryFilter,
rarityFilter,
page,
pageSize,
isAdminItemsResponse,
]);
useEffect(() => {
if (searchDebounce.current !== undefined) {
@@ -219,7 +281,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
set.add(item.rarity);
}
});
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
);
}, [data.items]);
const openEditModal = (item: AdminItemRecord) => {
@@ -257,8 +321,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: "Erro desconhecido" }));
throw new Error(error?.error || error?.message || "Falha ao salvar item");
const error = await res
.json()
.catch(() => ({ error: "Erro desconhecido" }));
throw new Error(
error?.error || error?.message || "Falha ao salvar item",
);
}
await fetchItems();
@@ -281,12 +349,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<div className="space-y-1">
<CardTitle>Todos os itens cadastrados</CardTitle>
<CardDescription>
Visualize e edite os registros unificados importados do database do Tibia Wiki.
Itens específicos de servidores podem ser marcados através das tags.
Visualize e edite os registros unificados importados do database
do Tibia Wiki. Itens específicos de servidores podem ser marcados
através das tags.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
<Button
variant="outline"
size="sm"
onClick={fetchItems}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
</Button>
<Button
@@ -309,7 +383,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<Select value={categoryFilter} onValueChange={(value) => { setCategoryFilter(value); setPage(1); }}>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Categoria" />
</SelectTrigger>
@@ -322,7 +402,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
))}
</SelectContent>
</Select>
<Select value={rarityFilter} onValueChange={(value) => { setRarityFilter(value); setPage(1); }}>
<Select
value={rarityFilter}
onValueChange={(value) => {
setRarityFilter(value);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="Raridade" />
</SelectTrigger>
@@ -340,7 +426,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{apiStatus.code !== null && (
<Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
{apiStatus.durationMs !== null
? ` • ${apiStatus.durationMs}ms`
: ""}
</Badge>
)}
</div>
@@ -350,7 +438,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{error && (
<Card className="border-destructive/40 bg-destructive/10">
<CardContent className="py-3 text-sm text-destructive">{error}</CardContent>
<CardContent className="py-3 text-sm text-destructive">
{error}
</CardContent>
</Card>
)}
@@ -382,26 +472,48 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<table className="w-full table-auto border-collapse min-w-[960px]">
<thead>
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
<th className="border border-border px-3 py-2 text-left">Imagem</th>
<th className="border border-border px-3 py-2 text-left">Nome</th>
<th className="border border-border px-3 py-2 text-left">Categoria</th>
<th className="border border-border px-3 py-2 text-left">Raridade</th>
<th className="border border-border px-3 py-2 text-left">Origem</th>
<th className="border border-border px-3 py-2 text-left">Servidores</th>
<th className="border border-border px-3 py-2 text-left">Atualizado em</th>
<th className="border border-border px-3 py-2 text-left">Ações</th>
<th className="border border-border px-3 py-2 text-left">
Imagem
</th>
<th className="border border-border px-3 py-2 text-left">
Nome
</th>
<th className="border border-border px-3 py-2 text-left">
Categoria
</th>
<th className="border border-border px-3 py-2 text-left">
Raridade
</th>
<th className="border border-border px-3 py-2 text-left">
Origem
</th>
<th className="border border-border px-3 py-2 text-left">
Servidores
</th>
<th className="border border-border px-3 py-2 text-left">
Atualizado em
</th>
<th className="border border-border px-3 py-2 text-left">
Ações
</th>
</tr>
</thead>
<tbody>
{itemsForDisplay.length === 0 ? (
<tr>
<td colSpan={8} className="border border-border px-3 py-6 text-center text-sm text-muted-foreground">
<td
colSpan={8}
className="border border-border px-3 py-6 text-center text-sm text-muted-foreground"
>
Nenhum item encontrado com os filtros atuais.
</td>
</tr>
) : (
itemsForDisplay.map((item) => (
<tr key={item.item_id} className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors">
<tr
key={item.item_id}
className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors"
>
<td className="border border-border px-3 py-2 align-middle">
{item.image_url ? (
<div className="relative h-12 w-12 overflow-hidden rounded border">
@@ -413,7 +525,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
/>
</div>
) : (
<span className="text-xs text-muted-foreground">Sem imagem</span>
<span className="text-xs text-muted-foreground">
Sem imagem
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top">
@@ -422,14 +536,21 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
#{item.item_id}
</div>
</td>
<td className="border border-border px-3 py-2 align-top text-sm">{item.category}</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.category}
</td>
<td className="border border-border px-3 py-2 align-top text-sm">
{item.rarity ? (
<Badge variant="outline" className="uppercase text-[10px]">
<Badge
variant="outline"
className="uppercase text-[10px]"
>
{item.rarity}
</Badge>
) : (
<span className="text-muted-foreground text-xs">-</span>
<span className="text-muted-foreground text-xs">
-
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
@@ -440,17 +561,27 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
{item.servers.length > 0 ? (
<div className="flex flex-wrap gap-1">
{item.servers.map((serverTag) => (
<Badge key={`${item.item_id}-${serverTag}`} variant="secondary" className="text-[10px] uppercase">
<Badge
key={`${item.item_id}-${serverTag}`}
variant="secondary"
className="text-[10px] uppercase"
>
{serverTag}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">Global</span>
<span className="text-muted-foreground text-xs">
Global
</span>
)}
</td>
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
{format(new Date(item.updated_at), "dd/MM/yyyy HH:mm", { locale: ptBR })}
{format(
new Date(item.updated_at),
"dd/MM/yyyy HH:mm",
{ locale: ptBR },
)}
</td>
<td className="border border-border px-3 py-2 align-top">
<Button
@@ -471,7 +602,8 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Página {page} • Total de {Math.max(1, Math.ceil(data.total / pageSize))}
Página {page} • Total de{" "}
{Math.max(1, Math.ceil(data.total / pageSize))}
</span>
<div className="flex items-center gap-2">
<Button
@@ -485,8 +617,16 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
<Button
variant="outline"
size="sm"
onClick={() => setPage((prev) => (prev >= Math.ceil(data.total / pageSize) ? prev : prev + 1))}
disabled={page >= Math.ceil(data.total / pageSize) || loading}
onClick={() =>
setPage((prev) =>
prev >= Math.ceil(data.total / pageSize)
? prev
: prev + 1,
)
}
disabled={
page >= Math.ceil(data.total / pageSize) || loading
}
>
Próxima
</Button>
@@ -510,4 +650,3 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
</div>
);
}
+99 -33
View File
@@ -1,13 +1,19 @@
"use client";
import { Loader2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ItemImageCapture, type ProcessedImage } from "./ItemImageCapture";
interface ItemAiAssistantProps {
locale: string;
@@ -86,7 +92,10 @@ 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.`]);
setLogs((prev) => [
...prev,
`Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`,
]);
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
@@ -96,7 +105,15 @@ export function ItemAiAssistant({
.replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase()
.replace(/[_\s]+/g, "-");
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
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);
@@ -105,12 +122,19 @@ export function ItemAiAssistant({
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 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 origemDados: string =
autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
const sugestoesNome = !autoNameDetected
? suggestNamesFromImages(images)
: [];
// Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = {
@@ -125,18 +149,35 @@ export function ItemAiAssistant({
? "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,
sugestoes:
sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
},
};
setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = {
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 },
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) {
@@ -160,12 +201,20 @@ export function ItemAiAssistant({
}
}
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
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)}`]);
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."]);
@@ -193,8 +242,8 @@ export function ItemAiAssistant({
Assistente IA para registro de item
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e metadados. Você pode ajustar os dados
antes de salvar.
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e
metadados. Você pode ajustar os dados antes de salvar.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -216,7 +265,10 @@ export function ItemAiAssistant({
<div className="space-y-2">
<label className="text-sm font-medium">Escopo</label>
<Select value={scope} onValueChange={(value: "global" | "server") => setScope(value)}>
<Select
value={scope}
onValueChange={(value: "global" | "server") => setScope(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -247,7 +299,9 @@ export function ItemAiAssistant({
)}
<div className="space-y-2">
<label className="text-sm font-medium">Descrição detalhada para a IA</label>
<label className="text-sm font-medium">
Descrição detalhada para a IA
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
@@ -257,10 +311,14 @@ export function ItemAiAssistant({
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Prints / evidências visuais</label>
<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>
<p className="text-xs text-emerald-600">
Imagem recebida — pronta para processamento.
</p>
)}
</div>
@@ -271,12 +329,17 @@ export function ItemAiAssistant({
)}
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleGenerate} disabled={disabled || loading}>
<Button
type="button"
onClick={handleGenerate}
disabled={disabled || loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gerar com IA
</Button>
<p className="text-xs text-muted-foreground">
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
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>
@@ -292,16 +355,20 @@ export function ItemAiAssistant({
</pre>
) : (
<p>
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os
campos do formulário.
Execute a análise para visualizar o JSON sugerido. Ele será
usado para preencher automaticamente os campos do formulário.
</p>
)}
{logs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
<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>
<p key={idx} className="text-[11px] leading-relaxed">
• {line}
</p>
))}
</div>
</div>
@@ -312,4 +379,3 @@ export function ItemAiAssistant({
</section>
);
}
@@ -41,3 +41,5 @@ export function ItemCreationModal({
);
}
// Reexporta o tipo para permitir importação de ItemFormPayload a partir de ItemCreationModal
export type { ItemFormPayload } from "./AdminItemForm";
+1 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { Upload, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { resizeImageToDataURL } from "@/lib/image/resize";
import { cn } from "@/lib/utils";
@@ -225,4 +225,3 @@ export function ItemImageCapture({
</div>
);
}
+108 -37
View File
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
type FeedbackState =
| { type: "success"; message: string }
@@ -38,7 +38,10 @@ function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
isActive: tier.is_active,
features: (tier.features ?? [])
.map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`)
.map(
(feature) =>
`${feature.feature_key}|${feature.label}|${feature.description ?? ""}`,
)
.join("\n"),
};
}
@@ -53,7 +56,9 @@ function parseFeatures(text: string) {
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? "");
const [featureKey, label, description] = line
.split("|")
.map((part) => part?.trim() ?? "");
return {
feature_key: featureKey,
label: label || featureKey,
@@ -79,8 +84,14 @@ export function AdminSubscriptionDashboard({
isActive: true,
features: "",
});
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(() =>
Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)]))
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
() =>
Object.fromEntries(
initialTiers.map((tier) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
]),
),
);
const [assignUserId, setAssignUserId] = useState("");
const [assignTierId, setAssignTierId] = useState<string>("free");
@@ -105,8 +116,8 @@ export function AdminSubscriptionDashboard({
data.tiers.map((tier: SubscriptionTierSummary) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
])
)
]),
),
);
}, []);
@@ -119,8 +130,12 @@ export function AdminSubscriptionDashboard({
slug: createForm.slug,
description: createForm.description || null,
level: Number.parseInt(createForm.level.toString(), 10),
priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null,
priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null,
priceMonthly: createForm.priceMonthly
? Number.parseFloat(createForm.priceMonthly)
: null,
priceYearly: createForm.priceYearly
? Number.parseFloat(createForm.priceYearly)
: null,
isActive: createForm.isActive,
features: parseFeatures(createForm.features),
};
@@ -156,7 +171,9 @@ export function AdminSubscriptionDashboard({
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível criar o plano.",
error instanceof Error
? error.message
: "Não foi possível criar o plano.",
});
} finally {
setIsLoading(false);
@@ -173,8 +190,12 @@ export function AdminSubscriptionDashboard({
slug: form.slug,
description: form.description || null,
level: Number.parseInt(form.level.toString(), 10),
priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null,
priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null,
priceMonthly: form.priceMonthly
? Number.parseFloat(form.priceMonthly)
: null,
priceYearly: form.priceYearly
? Number.parseFloat(form.priceYearly)
: null,
isActive: form.isActive,
features: parseFeatures(form.features),
};
@@ -194,12 +215,17 @@ export function AdminSubscriptionDashboard({
}
await refreshTiers();
setFeedback({ type: "success", message: "Plano atualizado com sucesso." });
setFeedback({
type: "success",
message: "Plano atualizado com sucesso.",
});
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível atualizar o plano.",
error instanceof Error
? error.message
: "Não foi possível atualizar o plano.",
});
} finally {
setIsLoading(false);
@@ -231,7 +257,9 @@ export function AdminSubscriptionDashboard({
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível remover o plano.",
error instanceof Error
? error.message
: "Não foi possível remover o plano.",
});
} finally {
setIsLoading(false);
@@ -242,7 +270,10 @@ export function AdminSubscriptionDashboard({
resetFeedback();
if (!assignUserId.trim()) {
setFeedback({ type: "error", message: "Informe um ID de usuário válido." });
setFeedback({
type: "error",
message: "Informe um ID de usuário válido.",
});
return;
}
@@ -290,7 +321,7 @@ export function AdminSubscriptionDashboard({
}
return a.level - b.level;
}),
[tiers]
[tiers],
);
return (
@@ -322,7 +353,10 @@ export function AdminSubscriptionDashboard({
placeholder="Ex.: Ultra"
value={createForm.name}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, name: event.target.value }))
setCreateForm((prev) => ({
...prev,
name: event.target.value,
}))
}
/>
</div>
@@ -335,7 +369,10 @@ export function AdminSubscriptionDashboard({
placeholder="ex.: ultra"
value={createForm.slug}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, slug: event.target.value }))
setCreateForm((prev) => ({
...prev,
slug: event.target.value,
}))
}
/>
</div>
@@ -365,7 +402,10 @@ export function AdminSubscriptionDashboard({
variant={createForm.isActive ? "default" : "outline"}
type="button"
onClick={() =>
setCreateForm((prev) => ({ ...prev, isActive: !prev.isActive }))
setCreateForm((prev) => ({
...prev,
isActive: !prev.isActive,
}))
}
>
{createForm.isActive ? "Ativo" : "Inativo"}
@@ -375,7 +415,10 @@ export function AdminSubscriptionDashboard({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-monthly">
<label
className="text-sm font-medium"
htmlFor="tier-price-monthly"
>
Preço mensal (opcional)
</label>
<Input
@@ -385,12 +428,18 @@ export function AdminSubscriptionDashboard({
step="0.01"
value={createForm.priceMonthly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value }))
setCreateForm((prev) => ({
...prev,
priceMonthly: event.target.value,
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-yearly">
<label
className="text-sm font-medium"
htmlFor="tier-price-yearly"
>
Preço anual (opcional)
</label>
<Input
@@ -400,7 +449,10 @@ export function AdminSubscriptionDashboard({
step="0.01"
value={createForm.priceYearly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value }))
setCreateForm((prev) => ({
...prev,
priceYearly: event.target.value,
}))
}
/>
</div>
@@ -415,7 +467,10 @@ export function AdminSubscriptionDashboard({
rows={3}
value={createForm.description}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, description: event.target.value }))
setCreateForm((prev) => ({
...prev,
description: event.target.value,
}))
}
/>
</div>
@@ -429,7 +484,10 @@ export function AdminSubscriptionDashboard({
rows={4}
value={createForm.features}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, features: event.target.value }))
setCreateForm((prev) => ({
...prev,
features: event.target.value,
}))
}
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
/>
@@ -452,7 +510,8 @@ export function AdminSubscriptionDashboard({
</CardHeader>
<CardContent className="space-y-6">
{orderedTiers.map((tier) => {
const formState = editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
const formState =
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
return (
<Card key={tier.subscription_tier_id} className="border-muted">
@@ -461,7 +520,10 @@ export function AdminSubscriptionDashboard({
<CardTitle className="flex items-center gap-2 text-xl">
{tier.name}
<Badge variant="secondary">{tier.slug}</Badge>
<Badge variant={tier.is_active ? "default" : "outline"} className="uppercase">
<Badge
variant={tier.is_active ? "default" : "outline"}
className="uppercase"
>
{tier.is_active ? "Ativo" : "Inativo"}
</Badge>
</CardTitle>
@@ -514,7 +576,10 @@ export function AdminSubscriptionDashboard({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
level: Number.parseInt(event.target.value || "0", 10),
level: Number.parseInt(
event.target.value || "0",
10,
),
},
}))
}
@@ -530,7 +595,8 @@ export function AdminSubscriptionDashboard({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
isActive: !prev[tier.subscription_tier_id].isActive,
isActive:
!prev[tier.subscription_tier_id].isActive,
},
}))
}
@@ -542,7 +608,9 @@ export function AdminSubscriptionDashboard({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Preço mensal</label>
<label className="text-sm font-medium">
Preço mensal
</label>
<Input
type="number"
min={0}
@@ -618,7 +686,9 @@ export function AdminSubscriptionDashboard({
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => handleUpdateTier(tier.subscription_tier_id)}
onClick={() =>
handleUpdateTier(tier.subscription_tier_id)
}
disabled={isLoading}
>
Salvar alterações
@@ -640,7 +710,9 @@ export function AdminSubscriptionDashboard({
<Button
type="button"
variant="destructive"
onClick={() => handleDeleteTier(tier.subscription_tier_id, tier.slug)}
onClick={() =>
handleDeleteTier(tier.subscription_tier_id, tier.slug)
}
disabled={isLoading}
>
Remover plano
@@ -654,7 +726,7 @@ export function AdminSubscriptionDashboard({
</CardContent>
</Card>
<Card>
<Card>
<CardHeader>
<CardTitle>Vincular plano a usuário</CardTitle>
</CardHeader>
@@ -698,4 +770,3 @@ export function AdminSubscriptionDashboard({
</div>
);
}