feat: setup docker and push project to remote
This commit is contained in:
278
src/app/dashboard/DashboardClient.tsx
Normal file
278
src/app/dashboard/DashboardClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
src/app/dashboard/configuracoes/ConfiguracoesClient.tsx
Normal file
255
src/app/dashboard/configuracoes/ConfiguracoesClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/dashboard/configuracoes/page.tsx
Normal file
26
src/app/dashboard/configuracoes/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1500
src/app/dashboard/documentos/DocumentsClient.tsx
Normal file
1500
src/app/dashboard/documentos/DocumentsClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
370
src/app/dashboard/documentos/[id]/DocumentDetailClient.tsx
Normal file
370
src/app/dashboard/documentos/[id]/DocumentDetailClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/app/dashboard/documentos/[id]/page.tsx
Normal file
36
src/app/dashboard/documentos/[id]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
291
src/app/dashboard/documentos/novo/NewDocumentClient.tsx
Normal file
291
src/app/dashboard/documentos/novo/NewDocumentClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/dashboard/documentos/novo/page.tsx
Normal file
28
src/app/dashboard/documentos/novo/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/app/dashboard/documentos/page.tsx
Normal file
41
src/app/dashboard/documentos/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/app/dashboard/page.tsx
Normal file
22
src/app/dashboard/page.tsx
Normal 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: [] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
253
src/app/dashboard/perfil/ProfileClient.tsx
Normal file
253
src/app/dashboard/perfil/ProfileClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
src/app/dashboard/perfil/page.tsx
Normal file
18
src/app/dashboard/perfil/page.tsx
Normal 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!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
515
src/app/dashboard/usuarios/UsuariosClient.tsx
Normal file
515
src/app/dashboard/usuarios/UsuariosClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/dashboard/usuarios/page.tsx
Normal file
23
src/app/dashboard/usuarios/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user