feat: setup docker and push project to remote

This commit is contained in:
Erik Silva
2026-01-20 13:44:32 -03:00
parent 45bac0c990
commit 261fd429d5
74 changed files with 12876 additions and 101 deletions

View File

@@ -0,0 +1,278 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
FileText,
Plus,
TrendingUp,
Eye,
Calendar,
ChevronRight,
ArrowUpRight,
ArrowRight,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type DashboardStats = {
docCount: number;
viewCount: number;
downloadCount: number;
recentDocs: any[];
};
export default function DashboardClient({
user,
organization,
stats,
}: {
user: UserType;
organization: Organization;
stats: DashboardStats;
}) {
const primaryColor = organization.primaryColor || "#2563eb";
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
});
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto">
{/* Top Banner / Hero - Integrated Background */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 bg-white border border-slate-200 rounded-full text-[9px] font-bold uppercase tracking-[0.1em] text-slate-500">Overview Panel</span>
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1">Dashboard</h2>
<p className="text-sm text-slate-500 font-medium">
Bem-vindo, Administrador <span className="text-slate-900">{user.name?.split(' ')[0] || "Administrador"}</span>.
</p>
</div>
<div className="flex items-center gap-3">
<Button
asChild
style={{ backgroundColor: primaryColor }}
className="h-10 px-6 rounded-lg font-bold text-xs shadow-none hover:opacity-90 active:scale-95 transition-all text-white"
>
<Link href="/dashboard/documentos?upload=true">
<Plus size={18} className="mr-2 stroke-[3]" />
Novo Documento
</Link>
</Button>
</div>
</div>
{/* Faded accent circle */}
<div
className="absolute top-0 right-0 w-[300px] h-[300px] rounded-full blur-[80px] opacity-[0.03] pointer-events-none"
style={{ backgroundColor: primaryColor }}
/>
</div>
<div className="p-8 lg:p-10 w-full">
{/* Stats Grid - Large and minimalist */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-10">
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-blue-100 transition-colors">
<FileText size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-blue-50 text-blue-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Total de Documentos</p>
<h3 className="text-2xl font-black text-slate-900">{stats.docCount}</h3>
</div>
</div>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-green-100 transition-colors">
<Eye size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-green-50 text-green-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Visualizações Totais</p>
<h3 className="text-2xl font-black text-slate-900">{stats.viewCount.toLocaleString()}</h3>
</div>
</div>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<div className="group relative p-5 rounded-[20px] bg-slate-50 hover:bg-white border-2 border-transparent hover:border-slate-100 transition-all duration-500">
<div className="flex flex-col gap-3.5">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-slate-800 border border-slate-100 group-hover:border-purple-100 transition-colors">
<TrendingUp size={20} className="stroke-[2]" />
</div>
<div className="p-1 rounded-full bg-purple-50 text-purple-600">
<ArrowUpRight size={14} />
</div>
</div>
<div>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-0.5">Downloads Totais</p>
<h3 className="text-2xl font-black text-slate-900">{stats.downloadCount.toLocaleString()}</h3>
</div>
</div>
</div>
</motion.div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Recent Documents Table - More integrated */}
<motion.div initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} className="lg:col-span-8">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="text-lg font-black text-slate-900 tracking-tight">Atividade Recente</h3>
<Link href="/dashboard/documentos" className="group flex items-center gap-2 text-[9px] font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors">
Ver Todos <ArrowRight size={12} className="group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="bg-white rounded-[24px] border-2 border-slate-100 overflow-hidden">
{stats.recentDocs.length === 0 ? (
<div className="p-14 text-center">
<div className="w-14 h-14 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-3">
<FileText size={24} className="text-slate-300" />
</div>
<p className="text-slate-500 font-medium mb-4 text-sm">Nenhum documento encontrado.</p>
<Button asChild variant="outline" className="rounded-lg border-2 font-bold px-6 h-9 text-[10px] uppercase tracking-widest">
<Link href="/dashboard/documentos/novo">Começar Agora</Link>
</Button>
</div>
) : (
<div className="divide-y-2 divide-slate-50">
{stats.recentDocs.map((doc) => (
<Link
key={doc.id}
href={`/dashboard/documentos/${doc.id}`}
className="flex items-center gap-4 py-3.5 px-6 hover:bg-slate-50/50 transition-all group"
>
<div className="w-10 h-10 rounded-lg bg-slate-50 group-hover:bg-white border-2 border-transparent group-hover:border-slate-100 flex items-center justify-center transition-all shrink-0">
<FileText size={18} className="text-slate-500 group-hover:text-slate-900 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-black text-slate-900 group-hover:text-blue-600 transition-colors truncate mb-0.5 text-sm">{doc.title}</h4>
<div className="flex items-center gap-3 text-[9px] font-bold text-slate-400 uppercase tracking-widest">
<span>{doc.folder?.name || "Sem categoria"}</span>
<span className="w-1 h-1 rounded-full bg-slate-200" />
<span>{formatDate(doc.createdAt)}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="flex items-center gap-2 text-slate-400 group-hover:text-slate-900 transition-colors">
<span className="text-[10px] font-bold uppercase tracking-widest">Detalhes</span>
<ChevronRight size={14} className="stroke-[3]" />
</div>
</div>
</Link>
))}
</div>
)}
</div>
</motion.div>
{/* Summary Column */}
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }} className="lg:col-span-4 space-y-6">
<div className="bg-slate-900 rounded-[24px] p-6 text-white relative overflow-hidden group">
<div className="relative z-10">
<div className="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center mb-4">
<Calendar size={20} className="text-white" />
</div>
<h4 className="text-xl font-black tracking-tight mb-1">Status do Portal</h4>
<p className="text-white/60 text-xs font-medium leading-relaxed mb-6">
O portal está online e sincronizado com os últimos envios de documentos.
</p>
<Button variant="ghost" className="w-full bg-white/10 hover:bg-white/20 text-white font-bold text-[10px] uppercase tracking-widest h-10 rounded-lg border-none">
Ver Log de Atividades
</Button>
</div>
<div className="absolute -right-4 -bottom-4 w-32 h-32 bg-white/5 rounded-full blur-2xl group-hover:scale-150 transition-transform duration-700" />
</div>
<div className="bg-white border-2 border-slate-100 rounded-[24px] p-6">
<h4 className="text-base font-black text-slate-900 tracking-tight mb-4">Informações Rápidas</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Organização</span>
<span className="text-xs font-black text-slate-900">{organization.name}</span>
</div>
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Seu Perfil</span>
<span className="text-xs font-black text-slate-900 uppercase tracking-tighter">{user.role}</span>
</div>
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Data</span>
<span className="text-xs font-black text-slate-900">{new Date().toLocaleDateString('pt-BR')}</span>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</main>
</div>
);
}
function Users(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
}

View File

@@ -0,0 +1,255 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
Save,
CloudUpload,
Loader2,
Palette,
Image,
ChevronRight,
} from "lucide-react";
import Link from "next/link";
import { uploadFile } from "@/app/actions/upload";
import { updateOrganization } from "@/app/actions/organization";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
cnpj: string | null;
subdomain: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
export default function ConfiguracoesClient({
user,
organization,
}: {
user: UserType;
organization: Organization;
}) {
const router = useRouter();
const [primaryColor, setPrimaryColor] = useState(organization.primaryColor || "#2563eb");
const [name, setName] = useState(organization.name);
const [logoUrl, setLogoUrl] = useState(organization.logoUrl || "");
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
const result = await uploadFile(formData);
if (result.success && result.url) {
setLogoUrl(result.url);
} else {
setMessage({ type: "error", text: "Erro ao fazer upload da logo." });
}
setIsUploading(false);
};
const handleSave = async () => {
setIsSaving(true);
setMessage({ type: "", text: "" });
const result = await updateOrganization({
name,
logoUrl: logoUrl || undefined,
primaryColor,
});
if (result.success) {
setMessage({ type: "success", text: "Configurações salvas com sucesso!" });
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao salvar." });
}
setIsSaving(false);
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-10 lg:p-12">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-0.5 bg-white border border-slate-200 rounded-full text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Corporate Branding</span>
</div>
<h2 className="text-3xl font-black text-slate-900 tracking-tight mb-1">Configurações</h2>
<p className="text-base text-slate-500 font-medium">Personalize a identidade visual do seu portal de transparência.</p>
</div>
</div>
</div>
<div className="p-10 lg:p-12 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<div className="space-y-10">
{/* Identity Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Identidade</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Defina o nome oficial da organização e a marca que será exibida para o cidadão.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Nome da Organização</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-6 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-2">
<Label className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Logotipo Oficial</Label>
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="shrink-0 group relative">
{logoUrl ? (
<div className="w-24 h-24 rounded-2xl bg-white border-2 border-slate-100 p-3 flex items-center justify-center transition-all group-hover:scale-105">
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div>
) : (
<div
className="w-24 h-24 rounded-2xl flex items-center justify-center text-white text-2xl font-black border-4 border-white/20 shadow-none transition-all group-hover:scale-105"
style={{ backgroundColor: primaryColor }}
>
{name.charAt(0)}
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="relative border-2 border-dashed border-slate-200 rounded-2xl p-6 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:bg-white transition-all cursor-pointer group">
<input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleLogoUpload}
accept="image/*"
/>
{isUploading ? (
<Loader2 size={32} className="text-blue-500 animate-spin" />
) : (
<div className="text-center">
<CloudUpload size={32} className="mx-auto mb-2.5 group-hover:scale-110 transition-transform" />
<p className="text-[10px] font-black text-slate-900 uppercase tracking-widest leading-none">Alterar Logotipo</p>
<p className="text-[9px] font-bold text-slate-400 mt-1.5">Clique ou arraste (PNG, JPG até 5MB)</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Colors Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Cores e Estilo</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Escolha a cor primária que define a identidade do portal administrativo e público.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] space-y-8">
<div className="space-y-2">
<Label className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Cor de Destaque</Label>
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="relative">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="w-20 h-20 rounded-2xl border-4 border-white cursor-pointer overflow-hidden p-0"
/>
</div>
<div className="flex-1 w-full">
<Input
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-lg font-black px-6 uppercase focus:border-blue-100 transition-all shadow-none"
/>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-2 ml-1">Código HEX / Cor Primária</p>
</div>
</div>
</div>
{/* Live Preview Card */}
<div className="p-6 bg-white rounded-2xl border-2 border-slate-100 italic">
<h4 className="text-[9px] font-black text-slate-300 uppercase tracking-widest mb-4 not-italic">Visualização em Tempo Real</h4>
<div className="flex flex-wrap items-center gap-4">
<Button style={{ backgroundColor: primaryColor }} className="h-10 px-6 rounded-lg font-black uppercase text-[10px] tracking-widest shadow-none text-white hover:opacity-90">
Botão de Ação
</Button>
<Button variant="outline" style={{ borderColor: primaryColor, color: primaryColor }} className="h-10 px-6 rounded-lg font-black uppercase text-[10px] tracking-widest border-2 shadow-none hover:bg-slate-50">
Borda Colorida
</Button>
<div className="flex items-center gap-2.5 ml-2 not-italic">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white" style={{ backgroundColor: primaryColor }}>
<Palette size={16} />
</div>
<span className="font-black text-xs uppercase tracking-tight" style={{ color: primaryColor }}>Texto em Destaque</span>
</div>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="pt-8 border-t-2 border-slate-50 flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="h-11 px-10 rounded-xl font-black text-xs uppercase tracking-widest transition-all active:scale-95 shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={18} className="mr-2.5 stroke-[3]" />
)}
{isSaving ? "Gravando..." : "Salvar Configurações"}
</Button>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/app/actions/auth";
import { getOrganization } from "@/app/actions/organization";
import ConfiguracoesClient from "./ConfiguracoesClient";
export default async function ConfiguracoesPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const organization = await getOrganization();
if (!organization) {
redirect("/dashboard");
}
return (
<ConfiguracoesClient
user={session}
organization={organization}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,370 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
Save,
Trash2,
Eye,
Download,
Copy,
Check,
ChevronLeft,
FileText,
Settings,
Share2,
Info,
} from "lucide-react";
import Link from "next/link";
import { updateDocument, deleteDocument } from "@/app/actions/documents";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type DocumentType = {
id: string;
title: string;
description: string | null;
fileName: string;
fileUrl: string;
fileType: string;
fileSize: number;
isPublished: boolean;
isDownloadable: boolean;
folderId: string | null;
};
type FolderType = {
id: string;
name: string;
};
export default function DocumentDetailClient({
user,
organization,
document,
folders,
}: {
user: UserType;
organization: Organization;
document: DocumentType;
folders: FolderType[];
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [title, setTitle] = useState(document.title);
const [description, setDescription] = useState(document.description || "");
const [isPublished, setIsPublished] = useState(document.isPublished);
const [isDownloadable, setIsDownloadable] = useState(document.isDownloadable);
const [folderId, setFolderId] = useState(document.folderId || "__none__");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const [copied, setCopied] = useState(false);
const publicUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/documento/${document.id}`;
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
setMessage({ type: "", text: "" });
const result = await updateDocument(document.id, {
title,
description: description || undefined,
isPublished,
isDownloadable,
folderId: folderId === "__none__" ? null : folderId,
});
if (result.success) {
setMessage({ type: "success", text: "Documento atualizado com sucesso!" });
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao salvar." });
}
setIsSaving(false);
};
const handleDelete = async () => {
if (!confirm("Tem certeza que deseja excluir permanentemente este documento?")) return;
setIsDeleting(true);
const result = await deleteDocument(document.id);
if (result.success) {
router.push("/dashboard/documentos");
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao excluir." });
setIsDeleting(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(publicUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex-1 min-w-0">
<nav className="flex items-center gap-2 mb-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<Link href="/dashboard/documentos" className="hover:text-slate-900 transition-colors">Documentos</Link>
<ChevronLeft size={10} className="rotate-180" />
<span className="text-slate-900 truncate tracking-[0.1em]">Gerenciar Arquivo</span>
</nav>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1 truncate">{document.title}</h2>
<div className="flex items-center gap-3">
<p className="text-sm text-slate-500 font-medium truncate">{document.fileName}</p>
<Badge className={`h-5 rounded-full px-2 text-[8px] uppercase font-bold tracking-widest ${isPublished ? 'bg-green-50 text-green-600 border-green-100' : 'bg-slate-100 text-slate-500 border-transparent shadow-none'}`}>
{isPublished ? 'Publicado' : 'Rascunho'}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" asChild className="h-9 px-4 rounded-lg font-bold text-[10px] uppercase tracking-widest border-2 border-slate-100 hover:bg-white transition-all shadow-none">
<a href={document.fileUrl} target="_blank" rel="noreferrer">
<Eye size={16} className="mr-2 stroke-[3]" />
Visualizar
</a>
</Button>
<Button
variant="ghost"
onClick={handleDelete}
disabled={isDeleting}
className="h-9 px-4 rounded-lg font-bold text-[10px] uppercase tracking-widest border-2 border-transparent hover:bg-red-50 text-red-500 hover:text-red-600 transition-all shadow-none"
>
<Trash2 size={16} className="mr-2 stroke-[3]" />
Excluir
</Button>
</div>
</div>
</div>
<div className="p-8 lg:p-10 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Side: Form */}
<div className="lg:col-span-2 space-y-8">
{/* Metadata Editor */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-slate-900 flex items-center justify-center text-white">
<Settings size={14} />
</div>
<h3 className="text-lg font-black text-slate-900 tracking-tight">Metadados e Visibilidade</h3>
</div>
<form onSubmit={handleSave} className="bg-slate-50 p-6 rounded-[24px] space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Título de Exibição</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Descrição do Conteúdo</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="bg-white border-2 border-slate-100 rounded-xl text-sm font-medium p-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Pasta Destino</Label>
<Select value={folderId} onValueChange={setFolderId}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl shadow-xl">
<SelectItem value="__none__" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Pasta Raiz</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id} className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">{f.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-white p-5 rounded-xl border-2 border-slate-100 flex flex-col justify-center gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Visível ao Público</span>
<Switch checked={isPublished} onCheckedChange={setIsPublished} />
</div>
<div className="h-px bg-slate-50" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Download</span>
<Switch checked={isDownloadable} onCheckedChange={setIsDownloadable} />
</div>
</div>
</div>
<div className="pt-2">
<Button
type="submit"
disabled={isSaving}
className="h-10 px-8 rounded-lg font-bold text-[10px] uppercase tracking-widest transition-all active:scale-95 shadow-none w-full md:w-auto text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={16} className="mr-2 stroke-[3]" />
)}
{isSaving ? "Gravando..." : "Salvar Alterações"}
</Button>
</div>
</form>
</div>
</div>
{/* Right Side: Sidebar Info */}
<div className="space-y-8">
{/* Technical Specs */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center text-slate-600">
<Info size={14} />
</div>
<h3 className="text-[10px] font-bold text-slate-900 tracking-tight uppercase tracking-widest">Especificações</h3>
</div>
<div className="bg-white rounded-xl border-2 border-slate-100 p-5 space-y-3.5 shadow-none">
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Dimensões</span>
<span className="text-[11px] font-bold text-slate-900">{formatFileSize(document.fileSize)}</span>
</div>
<div className="h-px bg-slate-50" />
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Extensão</span>
<span className="text-[11px] font-bold text-slate-900 uppercase">{document.fileType.split("/")[1] || "Arquivo"}</span>
</div>
<div className="h-px bg-slate-50" />
<div className="flex justify-between items-center group">
<span className="text-[9px] font-bold uppercase tracking-widest text-slate-400">ID Digital</span>
<span className="text-[9px] font-medium text-slate-400 font-mono truncate max-w-[100px]">{document.id}</span>
</div>
</div>
</div>
{/* Public Share Box */}
{isPublished && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-blue-50 flex items-center justify-center text-blue-600">
<Share2 size={14} />
</div>
<h3 className="text-[10px] font-bold text-slate-900 tracking-tight uppercase tracking-widest">Compartilhamento</h3>
</div>
<div className="bg-slate-900 rounded-xl p-5 space-y-3.5 text-white overflow-hidden relative">
<div className="relative z-10">
<p className="text-[9px] font-bold uppercase tracking-widest text-white/40 mb-2.5">Link Público de Acesso</p>
<div className="flex gap-2">
<Input
readOnly
value={publicUrl}
className="bg-white/10 border-white/10 h-9 text-[9px] font-medium rounded-lg text-white/80 focus:ring-0 shadow-none border-0"
/>
<Button
size="icon"
variant="secondary"
className={`shrink-0 h-9 w-9 rounded-lg transition-all ${copied ? 'bg-green-500 text-white' : 'bg-white text-slate-900'}`}
onClick={copyToClipboard}
>
{copied ? <Check size={16} className="stroke-[3]" /> : <Copy size={16} className="stroke-[3]" />}
</Button>
</div>
<p className="text-[8px] font-medium text-white/30 mt-2.5 leading-relaxed">
Cidadãos poderão visualizar este documento sem login através deste link permanente.
</p>
</div>
{/* Bg decoration */}
<div className="absolute top-0 right-0 w-20 h-20 bg-blue-500/20 blur-3xl rounded-full -mr-10 -mt-10" />
</div>
</div>
)}
</div>
</div>
</div>
</main>
</div>
);
}
function Loader2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getDocument } from "@/app/actions/documents";
import { getFolders } from "@/app/actions/folders";
import DocumentDetailClient from "./DocumentDetailClient";
export default async function DocumentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const { id } = await params;
const [document, folders] = await Promise.all([
getDocument(id),
getFolders(),
]);
if (!document) {
redirect("/dashboard/documentos");
}
return (
<DocumentDetailClient
user={session}
organization={session.organization!}
document={document}
folders={folders}
/>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
ArrowLeft,
CloudUpload,
Loader2,
Save,
FileText,
ChevronLeft,
} from "lucide-react";
import Link from "next/link";
import { uploadFile } from "@/app/actions/upload";
import { createDocument } from "@/app/actions/documents";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
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 { Switch } from "@/components/ui/switch";
import { Sidebar } from "@/components/Sidebar";
import { Alert, AlertDescription } from "@/components/ui/alert";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type FolderType = {
id: string;
name: string;
};
export default function NewDocumentClient({
user,
organization,
folders,
defaultFolderId,
}: {
user: UserType;
organization: Organization;
folders: FolderType[];
defaultFolderId?: string;
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [folderId, setFolderId] = useState(defaultFolderId || "__none__");
const [isPublished, setIsPublished] = useState(false);
const [isDownloadable, setIsDownloadable] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
if (!title) setTitle(selectedFile.name.split(".")[0]);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!file || !title) return;
setIsSaving(true);
setError("");
const formData = new FormData();
formData.append("file", file);
const uploadResult = await uploadFile(formData);
if (!uploadResult.success) {
setError(uploadResult.error || "Erro no upload.");
setIsSaving(false);
return;
}
const result = await createDocument({
title,
description: description || undefined,
fileUrl: uploadResult.url!,
fileName: file.name,
fileType: file.type,
fileSize: file.size,
folderId: folderId === "__none__" ? undefined : folderId,
isPublished,
isDownloadable,
});
if (result.success) {
router.push("/dashboard/documentos");
router.refresh();
} else {
setError(result.error || "Erro ao salvar.");
setIsSaving(false);
}
};
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden selection:bg-slate-900 selection:text-white">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex-1 min-w-0">
<nav className="flex items-center gap-2 mb-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<Link href="/dashboard/documentos" className="hover:text-slate-900 transition-colors">Documentos</Link>
<ChevronLeft size={10} className="rotate-180" />
<span className="text-slate-900 truncate tracking-[0.1em]">Upload de Arquivo</span>
</nav>
<h2 className="text-2xl font-black text-slate-900 tracking-tight leading-none italic">Novo Documento.</h2>
<p className="text-sm text-slate-500 font-medium mt-1">Carregue arquivos oficiais para o portal público.</p>
</div>
</div>
</div>
<main className="flex-1 overflow-y-auto">
<div className="p-8 lg:p-10 w-full">
{error && (
<Alert variant="destructive" className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{error}</AlertDescription>
</Alert>
)}
<div className="space-y-8">
{/* File Upload Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Arquivo</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Selecione o arquivo que será publicado. Formatos aceitos: PDF e Imagens.
</p>
</div>
<div className="lg:col-span-2">
<div className={`relative border-2 border-dashed rounded-[24px] p-8 transition-all duration-500 flex flex-col items-center justify-center text-center group ${file ? 'border-green-100 bg-green-50/30' : 'border-slate-100 bg-slate-50 hover:border-blue-200 hover:bg-white'}`}>
<input
type="file"
onChange={handleFileChange}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
{file ? (
<div className="space-y-3.5">
<div className="w-14 h-14 bg-white rounded-xl flex items-center justify-center mx-auto border-2 border-green-100 shadow-none scale-105">
<FileText size={24} className="text-green-500" />
</div>
<div>
<p className="text-base font-black text-slate-900 leading-tight">{file.name}</p>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-1">{(file.size / 1024 / 1024).toFixed(2)} MB Pronto para envio</p>
</div>
<Button
type="button"
variant="ghost"
onClick={(e) => { e.stopPropagation(); setFile(null); }}
className="relative z-20 text-red-500 hover:text-red-600 hover:bg-red-50 font-bold uppercase text-[9px] tracking-widest h-8 px-4 rounded-lg"
>
Trocar Arquivo
</Button>
</div>
) : (
<div className="space-y-3.5">
<div className="w-14 h-14 bg-white rounded-xl flex items-center justify-center mx-auto border-2 border-slate-50 group-hover:scale-105 transition-transform">
<CloudUpload size={24} className="text-slate-300" />
</div>
<div>
<p className="text-base font-black text-slate-900 leading-tight">Clique ou arraste o arquivo</p>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-1">Até 50MB permitidos por documento</p>
</div>
<Button
type="button"
className="h-9 px-5 rounded-lg font-bold uppercase text-[9px] tracking-widest pointer-events-none text-white font-bold"
style={{ backgroundColor: primaryColor }}
>
Procurar no Computador
</Button>
</div>
)}
</div>
</div>
</div>
{/* Details Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Informações</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Defina como o documento será identificado e em qual pasta ele ficará organizado.
</p>
</div>
<form onSubmit={handleSave} className="lg:col-span-2 bg-slate-50 p-6 rounded-[24px] space-y-5">
<div className="space-y-1.5">
<Label htmlFor="title" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Título do Documento</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Balanço Anual 2023"
className="h-10 bg-white border-none rounded-lg text-sm font-bold px-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Descrição Contextual (Opcional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Descreva brevemente para que serve este arquivo..."
className="bg-white border-none rounded-lg text-sm font-medium p-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Organizar na Pasta</Label>
<Select value={folderId} onValueChange={setFolderId}>
<SelectTrigger className="h-10 bg-white border-none rounded-lg text-sm font-bold px-4 focus:ring-4 focus:ring-slate-100 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-lg border-none shadow-none bg-slate-900 text-white">
<SelectItem value="__none__" className="font-bold text-[10px] uppercase p-2">Pasta Raiz</SelectItem>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id} className="font-bold text-[10px] uppercase p-2">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-white p-5 rounded-xl border-2 border-slate-100 flex flex-col justify-center gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Publicar Imediatamente</span>
<Switch checked={isPublished} onCheckedChange={setIsPublished} />
</div>
<div className="h-px bg-slate-50" />
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-tight text-slate-900">Permitir Download</span>
<Switch checked={isDownloadable} onCheckedChange={setIsDownloadable} />
</div>
</div>
</div>
<div className="pt-4 flex justify-end">
<Button
type="submit"
disabled={isSaving || !file || !title}
className="h-10 px-8 rounded-lg font-bold text-[10px] uppercase tracking-widest transition-all active:scale-95 shadow-none w-full md:w-auto text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin text-white" />
) : (
<Save size={16} className="mr-2 stroke-[3]" />
)}
{isSaving ? "Enviando Documento..." : "Salvar e Publicar"}
</Button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getFolders } from "@/app/actions/folders";
import NewDocumentClient from "./NewDocumentClient";
export default async function NewDocumentPage({
searchParams,
}: {
searchParams: Promise<{ folder?: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const params = await searchParams;
const folders = await getFolders();
return (
<NewDocumentClient
user={session}
organization={session.organization!}
folders={folders}
defaultFolderId={params.folder || null}
/>
);
}

View File

@@ -0,0 +1,41 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import { getFolders, getFolderBreadcrumb, getFolder, getAllFolders } from "@/app/actions/folders";
import { getDocuments } from "@/app/actions/documents";
import DocumentsClient from "./DocumentsClient";
export default async function DocumentsPage({
searchParams,
}: {
searchParams: Promise<{ folder?: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/");
}
const params = await searchParams;
const folderId = params.folder || null;
const [folders, documents, breadcrumb, currentFolder, allFolders] = await Promise.all([
getFolders(folderId),
getDocuments(folderId),
getFolderBreadcrumb(folderId),
folderId ? getFolder(folderId) : null,
getAllFolders(),
]);
return (
<DocumentsClient
user={session}
organization={session.organization!}
folders={folders}
documents={documents}
currentFolderId={folderId}
breadcrumb={breadcrumb}
currentFolder={currentFolder}
allFolders={allFolders}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { getSession } from "@/app/actions/auth";
import { getDashboardStats } from "@/app/actions/dashboard";
import { redirect } from "next/navigation";
import DashboardClient from "./DashboardClient";
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const stats = await getDashboardStats();
return (
<DashboardClient
user={session}
organization={session.organization!}
stats={stats || { docCount: 0, viewCount: 0, downloadCount: 0, recentDocs: [] }}
/>
);
}

View File

@@ -0,0 +1,253 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
Save,
Lock,
User,
Mail,
ArrowRight,
ChevronRight,
} from "lucide-react";
import { updateProfile } from "@/app/actions/profile";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Sidebar } from "@/components/Sidebar";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
export default function ProfileClient({
user,
organization,
}: {
user: UserType;
organization: Organization;
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
setMessage({ type: "", text: "" });
if (newPassword && newPassword !== confirmPassword) {
setMessage({ type: "error", text: "As novas senhas não coincidem." });
setIsSaving(false);
return;
}
if (newPassword && !currentPassword) {
setMessage({ type: "error", text: "Você deve informar a senha atual para alterá-la." });
setIsSaving(false);
return;
}
const result = await updateProfile({
name,
email,
currentPassword: currentPassword || undefined,
newPassword: newPassword || undefined,
});
if (result.success) {
setMessage({ type: "success", text: "Perfil atualizado com sucesso!" });
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao atualizar." });
}
setIsSaving(false);
};
return (
<div className="min-h-screen bg-white flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative border-b border-slate-100 bg-slate-50/40 p-10 lg:p-12">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-0.5 bg-white border border-slate-200 rounded-full text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Account Settings</span>
</div>
<h2 className="text-3xl font-black text-slate-900 tracking-tight mb-1">Meu Perfil</h2>
<p className="text-base text-slate-500 font-medium">Gerencie suas informações pessoais e credenciais de acesso.</p>
</div>
</div>
</div>
<div className="p-10 lg:p-12 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSave} className="space-y-10">
{/* Basic Info Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Dados Básicos</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Essas informações são usadas para identificar você no sistema e em registros de atividade.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] border-2 border-transparent">
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Nome Completo</Label>
<div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">E-mail de Trabalho</Label>
<div className="relative group">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* Security Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
<div>
<h3 className="text-lg font-black text-slate-900 tracking-tight mb-2">Segurança</h3>
<p className="text-xs text-slate-500 font-medium leading-relaxed">
Mantenha sua conta segura alterando sua senha regularmente. A senha atual é necessária para validar a troca.
</p>
</div>
<div className="lg:col-span-2 bg-slate-50 p-8 rounded-[32px] border-2 border-transparent space-y-6">
<div className="space-y-2">
<Label htmlFor="currentPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-600 ml-1">Senha Atual</Label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-blue-500 transition-colors" size={18} />
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="h-11 pl-12 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="Confirme para alterar"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="newPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">Nova Senha</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-[9px] font-black uppercase tracking-widest text-slate-400 ml-1">Confirmar Nova</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-11 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-100 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
placeholder="••••••••"
/>
</div>
</div>
</div>
</div>
{/* Submit Bar */}
<div className="pt-8 border-t-2 border-slate-50 flex justify-end">
<Button
type="submit"
disabled={isSaving}
className="h-11 px-8 rounded-xl font-black text-xs uppercase tracking-widest transition-all active:scale-95 shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isSaving ? (
<Loader2 className="mr-3 h-4 w-4 animate-spin" />
) : (
<Save size={18} className="mr-2.5 stroke-[3]" />
)}
{isSaving ? "Processando..." : "Salvar Alterações"}
</Button>
</div>
</form>
</div>
</main>
</div>
);
}
function Loader2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
}

View File

@@ -0,0 +1,18 @@
import { getSession } from "@/app/actions/auth";
import { redirect } from "next/navigation";
import ProfileClient from "./ProfileClient";
export default async function ProfilePage() {
const session = await getSession();
if (!session) {
redirect("/");
}
return (
<ProfileClient
user={session}
organization={session.organization!}
/>
);
}

View File

@@ -0,0 +1,515 @@
"use client";
import React, { useState } from "react";
import { motion } from "framer-motion";
import {
Plus,
Trash2,
Edit,
Shield,
ShieldCheck,
Eye,
ChevronRight,
ArrowRight,
Search,
Loader2,
AlertCircle,
} from "lucide-react";
import { logout } from "@/app/actions/auth";
import { createUser, updateUser, deleteUser } from "@/app/actions/users";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Sidebar } from "@/components/Sidebar";
import { StandardTable } from "@/components/StandardTable";
type Organization = {
id: string;
name: string;
logoUrl: string | null;
primaryColor: string;
};
type UserType = {
id: string;
name: string | null;
email: string;
role: string;
};
type UserListType = {
id: string;
name: string | null;
email: string;
role: string;
createdAt: Date;
};
export default function UsuariosClient({
user,
organization,
users,
}: {
user: UserType;
organization: Organization;
users: UserListType[];
}) {
const router = useRouter();
const primaryColor = organization.primaryColor || "#2563eb";
const [showNewUser, setShowNewUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<UserListType | null>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"ADMIN" | "EDITOR" | "VIEWER">("EDITOR");
// Search and Pagination
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 8;
const filteredUsers = users.filter(u =>
u.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const paginatedUsers = filteredUsers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
const resetForm = () => {
setName("");
setEmail("");
setPassword("");
setRole("EDITOR");
};
const handleCreate = async () => {
if (!name || !email || !password) {
setMessage({ type: "error", text: "Preencha todos os campos." });
return;
}
setIsLoading(true);
const result = await createUser({ name, email, password, role: role as any });
if (result.success) {
setShowNewUser(false);
resetForm();
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao criar usuário." });
}
setIsLoading(false);
};
const handleEdit = async () => {
if (!editingUser) return;
setIsLoading(true);
const result = await updateUser(editingUser.id, {
name: name || undefined,
email: email || undefined,
role: role as any,
password: password || undefined,
});
if (result.success) {
setShowEditUser(false);
setEditingUser(null);
resetForm();
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao atualizar usuário." });
}
setIsLoading(false);
};
const handleDelete = async () => {
if (!userToDelete) return;
setIsLoading(true);
const result = await deleteUser(userToDelete);
if (result.success) {
setShowDeleteDialog(false);
setUserToDelete(null);
router.refresh();
} else {
setMessage({ type: "error", text: result.error || "Erro ao excluir usuário." });
}
setIsLoading(false);
};
const openEditModal = (u: UserListType) => {
setEditingUser(u);
setName(u.name || "");
setEmail(u.email);
setRole(u.role as "ADMIN" | "EDITOR" | "VIEWER");
setPassword("");
setShowEditUser(true);
};
const getRoleBadge = (role: string) => {
switch (role) {
case "SUPER_ADMIN":
return <Badge className="bg-purple-50 text-purple-700 border-purple-100 font-black text-[10px] uppercase tracking-widest"><ShieldCheck size={12} className="mr-1.5" />Super Admin</Badge>;
case "ADMIN":
return <Badge className="bg-blue-50 text-blue-700 border-blue-100 font-black text-[10px] uppercase tracking-widest"><Shield size={12} className="mr-1.5" />Admin</Badge>;
case "EDITOR":
return <Badge className="bg-green-50 text-green-700 border-green-100 font-black text-[10px] uppercase tracking-widest"><Edit size={12} className="mr-1.5" />Editor</Badge>;
case "VIEWER":
return <Badge className="bg-slate-50 text-slate-500 border-slate-200 font-black text-[10px] uppercase tracking-widest"><Eye size={12} className="mr-1.5" />Viewer</Badge>;
default:
return <Badge variant="outline" className="font-black text-[10px] uppercase tracking-widest">{role}</Badge>;
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
return (
<div className="min-h-screen bg-[#f9fafb] flex">
<Sidebar user={user} organization={organization} />
<main className="flex-1 overflow-y-auto flex flex-col">
{/* Header Section - Integrated */}
<div className="relative bg-white p-8 lg:p-10">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 bg-white border border-slate-200 rounded-full text-[9px] font-bold uppercase tracking-[0.1em] text-slate-500">Access Control</span>
</div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-1">Usuários</h2>
<p className="text-sm text-slate-500 font-medium">Gerencie quem tem permissão para editar no portal.</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => {
resetForm();
setShowNewUser(true);
}}
style={{ backgroundColor: primaryColor }}
className="h-10 px-6 rounded-lg font-bold text-xs shadow-none hover:opacity-90 active:scale-95 transition-all text-white"
>
<Plus size={18} className="mr-2 stroke-[3]" />
Novo Usuário
</Button>
</div>
</div>
</div>
<div className="p-8 lg:p-10 w-full">
{message.text && (
<Alert variant={message.type === "success" ? "default" : "destructive"} className="mb-6 rounded-xl border-2">
<AlertDescription className="font-bold text-xs">{message.text}</AlertDescription>
</Alert>
)}
{/* Users List using StandardTable */}
<StandardTable
searchTerm={searchTerm}
onSearchChange={(val) => { setSearchTerm(val); setCurrentPage(1); }}
searchPlaceholder="Buscar usuários por nome ou e-mail..."
totalItems={filteredUsers.length}
showingCount={paginatedUsers.length}
itemName="usuários"
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
columns={
<>
<th className="pl-6 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest">Nome do Usuário</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest">E-mail</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest text-center">Acesso</th>
<th className="px-4 py-3.5 text-[9px] font-bold text-slate-600 uppercase tracking-widest text-right">Membro desde</th>
<th className="pr-6 py-3.5 w-32 text-right text-[9px] font-bold text-slate-600 uppercase tracking-widest">Ações</th>
</>
}
>
{paginatedUsers.map((u) => (
<tr key={u.id} className="hover:bg-blue-50/40 transition-all group cursor-default">
<td className="pl-6 py-2.5">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 border border-white"
style={{ backgroundColor: primaryColor }}
>
{u.name?.charAt(0) || u.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-slate-700 text-sm leading-none group-hover:text-red-600 transition-colors uppercase tracking-tight">{u.name || "Sem nome"}</p>
{u.id === user.id && (
<span className="text-[9px] font-bold text-red-500 uppercase tracking-tight mt-0.5 block">Você</span>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5 text-xs font-medium text-slate-500">
{u.email}
</td>
<td className="px-4 py-2.5 text-center">
{getRoleBadge(u.role)}
</td>
<td className="px-4 py-2.5 text-right text-[11px] font-medium text-slate-400">
{formatDate(u.createdAt)}
</td>
<td className="pr-6 py-2.5 text-right">
<div className="flex items-center justify-end gap-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-slate-400 hover:text-slate-900 hover:bg-slate-100 transition-all"
onClick={() => openEditModal(u)}
>
<Edit size={14} />
</Button>
{u.id !== user.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all"
onClick={() => {
setUserToDelete(u.id);
setShowDeleteDialog(true);
}}
>
<Trash2 size={14} />
</Button>
)}
</div>
</td>
</tr>
))}
</StandardTable>
</div>
{/* Dialogs Integrated */}
<Dialog open={showNewUser} onOpenChange={setShowNewUser}>
<DialogContent className="max-w-md bg-white border-2 border-slate-100 rounded-[24px] p-0 overflow-hidden">
<div className="p-8 border-b border-slate-50 bg-slate-50/50">
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight leading-none italic">Novo Usuário.</DialogTitle>
<p className="text-sm text-slate-500 mt-2 font-medium">Cadastre um novo membro para a equipe.</p>
</div>
<form onSubmit={handleCreate} className="p-8 space-y-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nome Completo</Label>
<Input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="João Silva"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">E-mail de Acesso</Label>
<Input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="joao@portal.gov.br"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Senha Inicial</Label>
<Input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nível de Acesso</Label>
<Select value={role} onValueChange={(v: any) => setRole(v)}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl">
<SelectItem value="ADMIN" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Admin</SelectItem>
<SelectItem value="EDITOR" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Editor</SelectItem>
<SelectItem value="VIEWER" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Visualizador</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-2 flex gap-3">
<Button
type="button"
variant="ghost"
onClick={() => setShowNewUser(false)}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest"
>
Cancelar
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Criar Usuário"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={showEditUser} onOpenChange={setShowEditUser}>
<DialogContent className="max-w-md bg-white border-2 border-slate-100 rounded-[24px] p-0 overflow-hidden">
<div className="p-8 border-b border-slate-50 bg-slate-50/50">
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight leading-none italic">Editar Usuário.</DialogTitle>
<p className="text-sm text-slate-500 mt-2 font-medium">Altere as informações de acesso deste membro.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleEdit(); }} className="p-8 space-y-5">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nome Completo</Label>
<Input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="João Silva"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">E-mail de Acesso</Label>
<Input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="joao@portal.gov.br"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nova Senha (Opcional)</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Manter atual"
className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 ml-0.5">Nível de Acesso</Label>
<Select value={role} onValueChange={(v: any) => setRole(v)}>
<SelectTrigger className="h-10 bg-white border-2 border-slate-100 rounded-xl text-sm font-bold px-4 focus:border-blue-200 focus:ring-4 focus:ring-blue-50/50 transition-all shadow-none text-slate-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white border-2 border-slate-100 rounded-xl">
<SelectItem value="ADMIN" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Admin</SelectItem>
<SelectItem value="EDITOR" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Editor</SelectItem>
<SelectItem value="VIEWER" className="font-bold text-[10px] uppercase p-2 text-slate-600 hover:bg-slate-50 cursor-pointer">Visualizador</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-2 flex gap-3">
<Button
type="button"
variant="ghost"
onClick={() => setShowEditUser(false)}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest"
>
Cancelar
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex-1 h-10 rounded-lg font-bold text-[10px] uppercase tracking-widest shadow-none text-white"
style={{ backgroundColor: primaryColor }}
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Salvar Alterações"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-600 mb-2">
<AlertCircle size={24} strokeWidth={2.5} />
</div>
<AlertDialogTitle>Excluir Usuário?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. O usuário perderá o acesso ao portal imediatamente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isLoading}
className="bg-red-600 hover:bg-red-700 active:bg-red-800"
>
{isLoading ? <Loader2 className="animate-spin" size={16} /> : "Sim, Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/app/actions/auth";
import { getUsers } from "@/app/actions/users";
import UsuariosClient from "@/app/dashboard/usuarios/UsuariosClient";
export default async function UsuariosPage() {
const session = await getSession();
if (!session) {
redirect("/");
}
const users = await getUsers();
return (
<UsuariosClient
user={session}
organization={session.organization!}
users={users}
/>
);
}