fix(erp): enable erp pages and menu items
This commit is contained in:
199
front-end-agency/components/documentos/DocumentEditor.tsx
Normal file
199
front-end-agency/components/documentos/DocumentEditor.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Document, docApi } from '@/lib/api-docs';
|
||||
import {
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
ArrowLeftIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
HashtagIcon,
|
||||
CloudArrowUpIcon,
|
||||
SparklesIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import NotionEditor from './NotionEditor';
|
||||
import DocumentSidebar from './DocumentSidebar';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
initialDocument: Partial<Document> | null;
|
||||
onSave: (doc: Partial<Document>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function DocumentEditor({ initialDocument, onSave, onCancel }: DocumentEditorProps) {
|
||||
const [document, setDocument] = useState<Partial<Document> | null>(initialDocument);
|
||||
const [title, setTitle] = useState(initialDocument?.title || '');
|
||||
const [content, setContent] = useState(initialDocument?.content || '[]');
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Refs para controle fino de salvamento
|
||||
const saveTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaved = useRef({ title: initialDocument?.title || '', content: initialDocument?.content || '[]' });
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDocument) {
|
||||
setDocument(initialDocument);
|
||||
setTitle(initialDocument.title || '');
|
||||
setContent(initialDocument.content || '[]');
|
||||
lastSaved.current = {
|
||||
title: initialDocument.title || '',
|
||||
content: initialDocument.content || '[]'
|
||||
};
|
||||
}
|
||||
}, [initialDocument]);
|
||||
|
||||
// Função de Auto-Save Robusta
|
||||
const autoSave = useCallback(async (newTitle: string, newContent: string) => {
|
||||
if (!document?.id) return;
|
||||
|
||||
setSaving(true);
|
||||
console.log('💾 Inactivity detected. Saving document...', document.id);
|
||||
|
||||
try {
|
||||
await docApi.updateDocument(document.id, {
|
||||
title: newTitle,
|
||||
content: newContent,
|
||||
status: 'published'
|
||||
});
|
||||
// Atualiza o ref do último salvo para evitar loop
|
||||
lastSaved.current = { title: newTitle, content: newContent };
|
||||
console.log('✅ Document saved successfully');
|
||||
} catch (e) {
|
||||
console.error('❌ Auto-save failed', e);
|
||||
toast.error('Erro ao salvar automaticamente');
|
||||
} finally {
|
||||
// Delay visual para o feedback de "Salvo"
|
||||
setTimeout(() => setSaving(false), 800);
|
||||
}
|
||||
}, [document?.id]);
|
||||
|
||||
// Trigger de auto-save com debounce de 1 segundo
|
||||
useEffect(() => {
|
||||
if (!document?.id) return;
|
||||
|
||||
// Verifica se houve mudança real em relação ao último salvo
|
||||
if (title === lastSaved.current.title && content === lastSaved.current.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
|
||||
saveTimeout.current = setTimeout(() => {
|
||||
autoSave(title, content);
|
||||
}, 1000); // Salva após 1 segundo de inatividade
|
||||
|
||||
return () => {
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
};
|
||||
}, [title, content, document?.id, autoSave]);
|
||||
|
||||
const navigateToDoc = async (doc: Document) => {
|
||||
// Antes de navegar, salva o atual se necessário
|
||||
if (title !== lastSaved.current.title || content !== lastSaved.current.content) {
|
||||
await autoSave(title, content);
|
||||
}
|
||||
|
||||
setDocument(doc);
|
||||
setTitle(doc.title);
|
||||
setContent(doc.content);
|
||||
lastSaved.current = { title: doc.title, content: doc.content };
|
||||
toast.success(`Abrindo: ${doc.title || 'Untitled'}`, { duration: 1000, position: 'bottom-center' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] bg-white dark:bg-zinc-950 flex flex-col">
|
||||
{/* Header Clean */}
|
||||
<header className="h-16 border-b border-zinc-200 dark:border-zinc-800 px-6 flex items-center justify-between bg-white dark:bg-zinc-950 z-20 shrink-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (title !== lastSaved.current.title || content !== lastSaved.current.content) {
|
||||
await autoSave(title, content);
|
||||
}
|
||||
onCancel();
|
||||
}}
|
||||
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-all"
|
||||
title="Back to list"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-xl transition-all ${showSidebar ? 'text-brand-500 bg-brand-50/50 dark:bg-brand-500/10' : 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-900'}`}
|
||||
title={showSidebar ? "Hide Navigation" : "Show Navigation"}
|
||||
>
|
||||
<Bars3BottomLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-zinc-200 dark:bg-zinc-800 mx-2" />
|
||||
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<HashtagIcon className="w-4 h-4 text-zinc-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Untitled Document"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none text-zinc-900 dark:text-white placeholder:text-zinc-300 dark:placeholder:text-zinc-600 w-full max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800">
|
||||
{saving ? (
|
||||
<div className="flex items-center gap-2 text-brand-500">
|
||||
<div className="w-1.5 h-1.5 bg-brand-500 rounded-full animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest leading-none">Saving...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-emerald-500">
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500 dark:text-zinc-400 leading-none">Sync Saved</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Lateral Sidebar (Navegação) */}
|
||||
{showSidebar && document?.id && (
|
||||
<DocumentSidebar
|
||||
key={document.id} // Re-render sidebar on doc change to ensure fresh data
|
||||
documentId={document.id}
|
||||
onNavigate={navigateToDoc}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Área do Editor */}
|
||||
<main className="flex-1 overflow-y-auto bg-white dark:bg-zinc-950 flex flex-col items-center custom-scrollbar">
|
||||
<div className="w-full max-w-[850px] px-12 md:px-24 py-16 animate-in slide-in-from-bottom-2 duration-500">
|
||||
{/* Title Hero Display */}
|
||||
<div className="mb-14 group">
|
||||
<h1 className="text-5xl font-black text-zinc-900 dark:text-white tracking-tighter leading-tight">
|
||||
{title || 'Untitled'}
|
||||
</h1>
|
||||
<div className="mt-6 flex items-center gap-4 text-zinc-400">
|
||||
<SparklesIcon className="w-4 h-4 text-brand-500" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-brand-500 flex items-center justify-center text-[10px] font-bold text-white uppercase">Me</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Editing Mode • Real-time Sync</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotionEditor
|
||||
documentId={document?.id}
|
||||
initialContent={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
front-end-agency/components/documentos/DocumentList.tsx
Normal file
86
front-end-agency/components/documentos/DocumentList.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Document } from '@/lib/api-docs';
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
CalendarIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Document[];
|
||||
onEdit: (doc: Document) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function DocumentList({ documents, onEdit, onDelete }: DocumentListProps) {
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-12 text-center">
|
||||
<DocumentTextIcon className="w-12 h-12 text-zinc-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Nenhum documento ainda</h3>
|
||||
<p className="text-zinc-500 max-w-xs mx-auto mt-2">
|
||||
Comece criando seu primeiro documento de texto para sua agência.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{documents.map((doc) => (
|
||||
<Card
|
||||
key={doc.id}
|
||||
className="group hover:shadow-xl transition-all border-2 border-transparent hover:border-brand-500/20"
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-brand-50 dark:bg-brand-500/10 rounded-xl">
|
||||
<DocumentTextIcon className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
||||
<button
|
||||
onClick={() => onEdit(doc)}
|
||||
className="p-2 text-zinc-400 hover:text-brand-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
<PencilSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(doc.id)}
|
||||
className="p-2 text-zinc-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-bold text-zinc-900 dark:text-white mb-2 line-clamp-1">
|
||||
{doc.title || 'Documento sem título'}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-zinc-500 line-clamp-3 mb-6 flex-1">
|
||||
{doc.content ? doc.content.replace(/<[^>]*>/g, '').substring(0, 150) : 'Sem conteúdo...'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<CalendarIcon className="w-3.5 h-3.5 text-zinc-400" />
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">
|
||||
{format(parseISO(doc.updated_at), "dd 'de' MMMM", { locale: ptBR })}
|
||||
</span>
|
||||
{doc.status === 'draft' && (
|
||||
<span className="ml-auto text-[10px] font-black uppercase tracking-widest text-amber-500 bg-amber-50 dark:bg-amber-500/10 px-2 py-0.5 rounded-full">
|
||||
Rascunho
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
front-end-agency/components/documentos/DocumentSidebar.tsx
Normal file
225
front-end-agency/components/documentos/DocumentSidebar.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Document, DocumentActivity, docApi } from '@/lib/api-docs';
|
||||
import {
|
||||
ClockIcon,
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
ArrowUpIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface DocumentSidebarProps {
|
||||
documentId: string;
|
||||
onNavigate: (doc: Document) => void;
|
||||
}
|
||||
|
||||
export default function DocumentSidebar({ documentId, onNavigate }: DocumentSidebarProps) {
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null);
|
||||
const [children, setChildren] = useState<Document[]>([]);
|
||||
const [parentDoc, setParentDoc] = useState<Document | null>(null);
|
||||
const [rootPages, setRootPages] = useState<Document[]>([]);
|
||||
const [activities, setActivities] = useState<DocumentActivity[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'subpages' | 'activities'>('subpages');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
console.log('🔍 Sidebar fetching data for:', documentId);
|
||||
|
||||
const fetchSafely = async (promise: Promise<any>) => {
|
||||
try { return await promise; }
|
||||
catch (e) { console.warn('Sidebar part failed:', e); return null; }
|
||||
};
|
||||
|
||||
try {
|
||||
const [doc, roots, logs, subpages] = await Promise.all([
|
||||
docApi.getDocument(documentId),
|
||||
fetchSafely(docApi.getDocuments()),
|
||||
fetchSafely(docApi.getActivities(documentId)),
|
||||
fetchSafely(docApi.getSubpages(documentId))
|
||||
]);
|
||||
|
||||
if (doc) {
|
||||
setCurrentDoc(doc);
|
||||
setChildren(subpages || []);
|
||||
if (doc.parent_id) {
|
||||
const parent = await fetchSafely(docApi.getDocument(doc.parent_id));
|
||||
setParentDoc(parent);
|
||||
} else {
|
||||
setParentDoc(null);
|
||||
}
|
||||
}
|
||||
|
||||
setRootPages(roots || []);
|
||||
setActivities(logs || []);
|
||||
} catch (e) {
|
||||
console.error('❌ Sidebar critical error:', e);
|
||||
toast.error('Erro ao carregar estrutura');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSubpage = async () => {
|
||||
try {
|
||||
const newDoc = await docApi.createDocument({
|
||||
title: 'Nova Subpágina',
|
||||
parent_id: documentId as any,
|
||||
content: '{"type":"doc","content":[{"type":"paragraph"}]}',
|
||||
status: 'published'
|
||||
});
|
||||
toast.success('Página criada!');
|
||||
// Refresh local para aparecer imediatamente
|
||||
setChildren(prev => [...prev, newDoc]);
|
||||
onNavigate(newDoc);
|
||||
} catch (e) {
|
||||
toast.error('Erro ao criar subpágina');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredChildren = children.filter(p => p.title.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const rootWikis = rootPages.filter(p => !p.parent_id);
|
||||
|
||||
return (
|
||||
<div className="w-72 border-r border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950 flex flex-col h-full overflow-hidden shrink-0">
|
||||
{/* Tabs de Navegação */}
|
||||
<div className="flex border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950">
|
||||
<button
|
||||
onClick={() => setActiveTab('subpages')}
|
||||
className={`flex-1 px-4 py-4 text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'subpages' ? 'text-brand-500 bg-brand-50/10 border-b-2 border-brand-500' : 'text-zinc-500'}`}
|
||||
>
|
||||
Estrutura
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('activities')}
|
||||
className={`flex-1 px-4 py-4 text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'activities' ? 'text-brand-500 bg-brand-50/10 border-b-2 border-brand-500' : 'text-zinc-500'}`}
|
||||
>
|
||||
Histórico
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-6">
|
||||
{activeTab === 'subpages' ? (
|
||||
<>
|
||||
{/* Status do Nível Atual */}
|
||||
<div className="space-y-3">
|
||||
{parentDoc && (
|
||||
<button
|
||||
onClick={() => onNavigate(parentDoc)}
|
||||
className="w-full flex items-center gap-2 p-2 px-3 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-900 text-zinc-400 hover:text-brand-500 transition-all group border border-dashed border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<ArrowUpIcon className="w-3 h-3 transition-transform group-hover:-translate-y-0.5" />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest truncate">Pai: {parentDoc.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="p-3 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-sm flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-50 dark:bg-brand-500/10 flex items-center justify-center text-brand-500">
|
||||
<FolderOpenIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-tighter">Você está em</p>
|
||||
<p className="text-xs font-bold text-zinc-900 dark:text-white truncate">{currentDoc?.title || 'Sem título'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 transition-colors ${loading ? 'text-brand-500 animate-pulse' : 'text-zinc-400'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar subpáginas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl pl-10 pr-4 py-2 text-xs outline-none focus:ring-2 ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subpages (Children) */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between px-2 mb-2">
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">Subpáginas ({children.length})</p>
|
||||
<button
|
||||
onClick={handleCreateSubpage}
|
||||
className="p-1 text-zinc-400 hover:text-brand-500 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md shadow-sm transition-all"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredChildren.length === 0 && (
|
||||
<div className="text-center py-6 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl">
|
||||
<p className="text-[10px] text-zinc-400 uppercase font-black">Nenhuma subpágina</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredChildren.map(page => (
|
||||
<button
|
||||
key={page.id}
|
||||
onClick={() => onNavigate(page)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all group border border-transparent hover:border-zinc-100 dark:hover:border-zinc-800 ${documentId === page.id ? 'bg-brand-500 text-white shadow-lg' : 'hover:bg-white dark:hover:bg-zinc-900 text-zinc-600 dark:text-zinc-400'}`}
|
||||
>
|
||||
<DocumentIcon className={`w-4 h-4 ${documentId === page.id ? 'text-white' : 'text-zinc-300 group-hover:text-brand-500'}`} />
|
||||
<span className="text-xs font-bold truncate flex-1 text-left">{page.title || 'Subpágina'}</span>
|
||||
<ChevronRightIcon className={`w-3 h-3 opacity-0 group-hover:opacity-100 ${documentId === page.id ? 'text-white' : ''}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Root wikis */}
|
||||
{!searchTerm && (
|
||||
<div className="pt-4 border-t border-zinc-100 dark:border-zinc-800 space-y-1">
|
||||
<p className="px-3 text-[10px] font-black text-zinc-400 uppercase tracking-widest mb-3">Todas as Wikis</p>
|
||||
{rootWikis.map(page => (
|
||||
<button
|
||||
key={page.id}
|
||||
onClick={() => onNavigate(page)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-xl transition-all group ${documentId === page.id ? 'text-brand-500 font-bold' : 'text-zinc-500 hover:bg-white dark:hover:bg-zinc-900'}`}
|
||||
>
|
||||
<HomeIcon className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-semibold truncate flex-1 text-left">{page.title || 'Início'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{activities.map(log => (
|
||||
<div key={log.id} className="flex gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-[10px] font-black text-zinc-400 shrink-0">
|
||||
{log.user_name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="text-[11px] text-zinc-900 dark:text-zinc-100 leading-tight">
|
||||
<span className="font-bold">{log.user_name}</span> {log.description}
|
||||
</p>
|
||||
<span className="text-[9px] text-zinc-400 uppercase font-black block mt-1">
|
||||
{format(parseISO(log.created_at), "dd MMM, HH:mm", { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
front-end-agency/components/documentos/NotionEditor.tsx
Normal file
233
front-end-agency/components/documentos/NotionEditor.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import Heading from '@tiptap/extension-heading';
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
ListBulletIcon,
|
||||
HashtagIcon,
|
||||
CodeBracketIcon,
|
||||
PlusIcon,
|
||||
Bars2Icon
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface NotionEditorProps {
|
||||
initialContent: string;
|
||||
onChange: (jsonContent: string) => void;
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
export default function NotionEditor({ initialContent, onChange, documentId }: NotionEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1, 2, 3],
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: "Comece a digitar ou aperte '/' para comandos...",
|
||||
emptyEditorClass: 'is-editor-empty',
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
],
|
||||
content: '',
|
||||
onUpdate: ({ editor }) => {
|
||||
const json = editor.getJSON();
|
||||
onChange(JSON.stringify(json));
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[600px] py-10 px-2 editor-content',
|
||||
},
|
||||
},
|
||||
}, [documentId]);
|
||||
|
||||
// Sincronizar apenas na primeira carga ou troca de documento
|
||||
useEffect(() => {
|
||||
if (!editor || !initialContent) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(initialContent);
|
||||
// Evita resetar se o editor já tem conteúdo (para não quebrar a digitação)
|
||||
if (editor.isEmpty && initialContent !== '{"type":"doc","content":[{"type":"paragraph"}]}') {
|
||||
editor.commands.setContent(parsed, false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (editor.isEmpty && initialContent !== '[]') {
|
||||
editor.commands.setContent(initialContent, false);
|
||||
}
|
||||
}
|
||||
}, [editor, documentId]); // Só roda quando o editor é criado ou o documento muda
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full relative min-h-[600px] group/editor selection:bg-brand-100 dark:selection:bg-brand-500/30">
|
||||
{/* Bubble Menu Customizado */}
|
||||
<BubbleMenu editor={editor} tippyOptions={{ duration: 150 }} className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-2xl p-1 gap-0.5 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
icon={<BoldIcon className="w-4 h-4" />}
|
||||
/>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
icon={<ItalicIcon className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-800 mx-1 self-center" />
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
label="H1"
|
||||
/>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
label="H2"
|
||||
/>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
icon={<ListBulletIcon className="w-4 h-4" />}
|
||||
/>
|
||||
</BubbleMenu>
|
||||
|
||||
{/* Menu Flutuante Estilo Notion */}
|
||||
<FloatingMenu editor={editor} tippyOptions={{ duration: 150 }} className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-2xl p-2 gap-1 overflow-hidden min-w-[240px] flex-col animate-in slide-in-from-left-4 duration-300">
|
||||
<p className="px-3 py-1 text-[10px] font-black uppercase tracking-widest text-zinc-400">Blocos Básicos</p>
|
||||
<FloatingItem
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
icon={<HashtagIcon className="w-4 h-4 text-brand-500" />}
|
||||
title="Título 1"
|
||||
desc="Título de seção grande"
|
||||
/>
|
||||
<FloatingItem
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
icon={<HashtagIcon className="w-4 h-4 text-brand-600" />}
|
||||
title="Título 2"
|
||||
desc="Título médio"
|
||||
/>
|
||||
<FloatingItem
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
icon={<ListBulletIcon className="w-4 h-4 text-emerald-500" />}
|
||||
title="Lista"
|
||||
desc="Lista simples com marcadores"
|
||||
/>
|
||||
<FloatingItem
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
icon={<CodeBracketIcon className="w-4 h-4 text-zinc-600" />}
|
||||
title="Código"
|
||||
desc="Bloco para trechos de código"
|
||||
/>
|
||||
</FloatingMenu>
|
||||
|
||||
{/* Área do Editor */}
|
||||
<div className="relative editor-shell">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
.tiptap {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
color: #374151;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
.dark .tiptap { color: #d1d5db; }
|
||||
|
||||
.tiptap h1 { font-size: 3.5rem; font-weight: 950; letter-spacing: -0.06em; margin-top: 3rem; margin-bottom: 1.5rem; color: #111827; line-height: 1.1; }
|
||||
.tiptap h2 { font-size: 2.2rem; font-weight: 800; letter-spacing: -0.04em; margin-top: 2.5rem; margin-bottom: 1rem; color: #1f2937; line-height: 1.2; }
|
||||
.tiptap h3 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; margin-top: 2rem; margin-bottom: 0.75rem; color: #374151; }
|
||||
|
||||
.dark .tiptap h1 { color: #f9fafb; }
|
||||
.dark .tiptap h2 { color: #f3f4f6; }
|
||||
.dark .tiptap h3 { color: #e5e7eb; }
|
||||
|
||||
.tiptap p { margin: 0.5rem 0; }
|
||||
.tiptap ul { list-style-type: disc; padding-left: 1.5rem; margin: 1rem 0; }
|
||||
.tiptap ol { list-style-type: decimal; padding-left: 1.5rem; margin: 1rem 0; }
|
||||
.tiptap li { margin: 0.25rem 0; }
|
||||
|
||||
.tiptap pre {
|
||||
background: #f8fafc;
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin: 2rem 0;
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.dark .tiptap pre { background: #0a0a0a; border-color: #1a1a1a; box-shadow: none; }
|
||||
|
||||
/* Efeito de Bloco Notion no Hover */
|
||||
.tiptap > * {
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.tiptap > *:hover::before {
|
||||
content: ':::';
|
||||
position: absolute;
|
||||
left: -2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #d1d5db;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dark .tiptap > *:hover::before { color: #334155; }
|
||||
`}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButton({ onClick, active, icon, label }: { onClick: () => void, active: boolean, icon?: React.ReactNode, label?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded-lg transition-all flex items-center justify-center min-w-[32px] ${active ? 'bg-brand-500 text-white shadow-lg' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500'}`}
|
||||
>
|
||||
{icon || <span className="text-xs font-black uppercase tracking-widest">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingItem({ onClick, icon, title, desc }: { onClick: () => void, icon: React.ReactNode, title: string, desc: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-4 p-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-xl text-left transition-all group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 flex items-center justify-center shadow-sm group-hover:scale-110 transition-transform">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-black text-zinc-900 dark:text-white uppercase tracking-widest">{title}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">{desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1064
front-end-agency/components/erp/FinanceContent.tsx
Normal file
1064
front-end-agency/components/erp/FinanceContent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, Suspense } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { SidebarRail, MenuItem } from './SidebarRail';
|
||||
import { TopBar } from './TopBar';
|
||||
@@ -20,11 +20,13 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menu
|
||||
<div className="flex h-screen w-full bg-gray-100 dark:bg-zinc-950 text-slate-900 dark:text-slate-100 overflow-hidden md:p-3 md:gap-3 transition-colors duration-300">
|
||||
{/* Sidebar controla seu próprio estado visual via props - Desktop Only */}
|
||||
<div className="hidden md:flex">
|
||||
<SidebarRail
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
menuItems={menuItems}
|
||||
/>
|
||||
<Suspense fallback={<div className="w-[80px] bg-white dark:bg-zinc-900" />}>
|
||||
<SidebarRail
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setIsExpanded(!isExpanded)}
|
||||
menuItems={menuItems}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Área de Conteúdo (Children) */}
|
||||
@@ -46,7 +48,9 @@ export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menu
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Bar */}
|
||||
<MobileBottomBar menuItems={menuItems} />
|
||||
<Suspense fallback={null}>
|
||||
<MobileBottomBar menuItems={menuItems} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { getUser, User, getToken, saveAuth } from '@/lib/auth';
|
||||
@@ -42,6 +42,7 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
|
||||
menuItems
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -340,23 +341,33 @@ export const SidebarRail: React.FC<SidebarRailProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 flex-1 overflow-y-auto">
|
||||
{activeMenuItem.subItems?.map((sub) => (
|
||||
<Link
|
||||
key={sub.href}
|
||||
href={sub.href}
|
||||
// onClick={() => setOpenSubmenu(null)} // Removido para manter fixo
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors mb-1
|
||||
${pathname === sub.href
|
||||
? 'bg-brand-50 dark:bg-brand-900/10 text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-zinc-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${pathname === sub.href ? 'bg-brand-500' : 'bg-gray-300 dark:bg-zinc-600'}`} />
|
||||
{sub.label}
|
||||
</Link>
|
||||
))}
|
||||
{activeMenuItem.subItems?.map((sub) => {
|
||||
// Lógica aprimorada de ativo para suportar query params (ex: ?tab=finance)
|
||||
const fullCurrentPath = searchParams.toString()
|
||||
? `${pathname}?${searchParams.toString()}`
|
||||
: pathname;
|
||||
|
||||
const isSubActive = sub.href.includes('?')
|
||||
? fullCurrentPath.includes(sub.href)
|
||||
: pathname === sub.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={sub.href}
|
||||
href={sub.href}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors mb-1
|
||||
${isSubActive
|
||||
? 'bg-brand-50 dark:bg-brand-900/10 text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-zinc-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isSubActive ? 'bg-brand-500' : 'bg-gray-300 dark:bg-zinc-600'}`} />
|
||||
{sub.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
29
front-end-agency/components/ui/Badge.tsx
Normal file
29
front-end-agency/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Badge({ children, variant = 'default', className = '' }: BadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||
success: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||
warning: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
error: 'bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400',
|
||||
info: 'bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`
|
||||
px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest inline-flex items-center
|
||||
${variants[variant]}
|
||||
${className}
|
||||
`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
64
front-end-agency/components/ui/BulkActionBar.tsx
Normal file
64
front-end-agency/components/ui/BulkActionBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export interface BulkAction {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: 'danger' | 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number;
|
||||
actions: BulkAction[];
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* BulkActionBar Component
|
||||
* A floating bar that appears when items are selected in a list/table.
|
||||
* Supports light/dark modes and custom actions.
|
||||
*/
|
||||
export default function BulkActionBar({ selectedCount, actions, onClearSelection }: BulkActionBarProps) {
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-10 duration-300">
|
||||
<div className="bg-white dark:bg-black border border-zinc-200 dark:border-zinc-900 text-zinc-900 dark:text-white rounded-[32px] px-8 py-4 shadow-[0_20px_50px_rgba(0,0,0,0.1)] dark:shadow-[0_20px_50px_rgba(0,0,0,0.5)] flex items-center gap-8 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3 pr-8 border-r border-zinc-200 dark:border-zinc-900">
|
||||
<span className="flex items-center justify-center w-8 h-8 bg-brand-500 rounded-full text-xs font-black text-white">
|
||||
{selectedCount}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400">Selecionados</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{actions.map((action, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={action.onClick}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl transition-all text-sm font-bold
|
||||
${action.variant === 'danger'
|
||||
? 'text-rose-500 hover:bg-rose-500/10'
|
||||
: action.variant === 'primary'
|
||||
? 'text-brand-600 dark:text-brand-400 hover:bg-brand-500/10'
|
||||
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="ml-4 p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title="Limpar seleção"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
front-end-agency/components/ui/Card.tsx
Normal file
55
front-end-agency/components/ui/Card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
headerAction?: ReactNode;
|
||||
noPadding?: boolean;
|
||||
onClick?: () => void;
|
||||
allowOverflow?: boolean;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className = "",
|
||||
headerAction,
|
||||
noPadding = false,
|
||||
onClick,
|
||||
allowOverflow = false
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 transition-all ${allowOverflow ? '' : 'overflow-hidden'} ${className}`}
|
||||
>
|
||||
{(title || description || headerAction) && (
|
||||
<div className="px-6 py-4 border-b border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
{title && (
|
||||
<h3 className="text-base font-bold text-zinc-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{headerAction && (
|
||||
<div>{headerAction}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={noPadding ? "" : "p-6"}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
front-end-agency/components/ui/CustomSelect.tsx
Normal file
103
front-end-agency/components/ui/CustomSelect.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: React.ReactNode;
|
||||
color?: string; // Cor para badge/ponto
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
options: SelectOption[];
|
||||
value: string | number;
|
||||
onChange: (value: any) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Selecione...",
|
||||
className = "",
|
||||
buttonClassName = ""
|
||||
}: CustomSelectProps) {
|
||||
const selected = options.find((opt) => opt.value === value) || null;
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className={`
|
||||
relative w-full cursor-pointer rounded-xl bg-white dark:bg-zinc-900 py-2.5 pl-4 pr-10 text-left text-sm font-semibold transition-all border
|
||||
${buttonClassName || 'border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400'}
|
||||
focus:outline-none focus:border-zinc-400 dark:focus:border-zinc-500
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{selected?.color && (
|
||||
<span className={`w-2 h-2 rounded-full ${selected.color}`} />
|
||||
)}
|
||||
{selected?.icon && <span>{selected.icon}</span>}
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 py-1 focus:outline-none sm:text-sm">
|
||||
{options.map((option, idx) => (
|
||||
<ListboxOption
|
||||
key={idx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2.5 pl-10 pr-4 transition-colors ${active ? "bg-zinc-50 dark:bg-zinc-800 text-brand-600 dark:text-brand-400" : "text-zinc-700 dark:text-zinc-300"
|
||||
}`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected: isSelected }) => (
|
||||
<>
|
||||
<span className={`flex items-center gap-2 truncate ${isSelected ? "font-bold" : "font-medium"}`}>
|
||||
{option.color && (
|
||||
<span className={`w-2 h-2 rounded-full ${option.color}`} />
|
||||
)}
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600 dark:text-brand-400">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
front-end-agency/components/ui/DataTable.tsx
Normal file
178
front-end-agency/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "./index";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface Column<T> {
|
||||
header: string;
|
||||
accessor: keyof T | ((item: T) => ReactNode);
|
||||
className?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems: number;
|
||||
};
|
||||
onRowClick?: (item: T) => void;
|
||||
selectable?: boolean;
|
||||
selectedIds?: (string | number)[];
|
||||
onSelectionChange?: (ids: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
export default function DataTable<T extends { id: string | number }>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
emptyMessage = "Nenhum resultado encontrado.",
|
||||
pagination,
|
||||
onRowClick,
|
||||
selectable = false,
|
||||
selectedIds = [],
|
||||
onSelectionChange
|
||||
}: DataTableProps<T>) {
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (e.target.checked) {
|
||||
onSelectionChange(data.map(item => item.id));
|
||||
} else {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectItem = (id: string | number) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter(i => i !== id));
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = data.length > 0 && selectedIds.length === data.length;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
|
||||
{selectable && (
|
||||
<th className="px-6 py-4 w-10 text-left">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={`px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider ${column.align === 'right' ? 'text-right' :
|
||||
column.align === 'center' ? 'text-center' : 'text-left'
|
||||
} ${column.className || ''}`}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
{columns.map((_, j) => (
|
||||
<td key={j} className="px-6 py-4">
|
||||
<div className="h-4 bg-zinc-100 dark:bg-zinc-800 rounded w-full"></div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-6 py-12 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={`transition-colors group ${isSelected ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''} ${onRowClick ? 'cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800/50' : 'hover:bg-zinc-50/50 dark:hover:bg-zinc-800/30'}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-6 py-4 w-10" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleSelectItem(item.id)}
|
||||
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<td
|
||||
key={index}
|
||||
className={`px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300 ${column.align === 'right' ? 'text-right' :
|
||||
column.align === 'center' ? 'text-center' : 'text-left'
|
||||
} ${column.className || ''}`}
|
||||
>
|
||||
{typeof column.accessor === 'function'
|
||||
? column.accessor(item)
|
||||
: (item[column.accessor] as ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="p-4 bg-zinc-50/30 dark:bg-zinc-900/30 border-t border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500 italic">
|
||||
Mostrando {data.length} de {pagination.totalItems} resultados
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.currentPage <= 1 || isLoading}
|
||||
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4 mr-1" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.currentPage >= pagination.totalPages || isLoading}
|
||||
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
|
||||
>
|
||||
Próximo
|
||||
<ChevronRightIcon className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
front-end-agency/components/ui/DatePicker.tsx
Normal file
242
front-end-agency/components/ui/DatePicker.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from "@headlessui/react";
|
||||
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
addDays,
|
||||
eachDayOfInterval,
|
||||
isWithinInterval,
|
||||
isBefore,
|
||||
subDays
|
||||
} from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: { start: Date | null; end: Date | null } | Date | null;
|
||||
onChange: (val: any) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
mode?: 'single' | 'range';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
mode = 'range',
|
||||
label
|
||||
}: DatePickerProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
|
||||
// Helper to normalize value
|
||||
const getRange = () => {
|
||||
if (mode === 'single') {
|
||||
const date = value instanceof Date ? value : (value as any)?.start || null;
|
||||
return { start: date, end: date };
|
||||
}
|
||||
const range = (value as { start: Date | null; end: Date | null }) || { start: null, end: null };
|
||||
return range;
|
||||
};
|
||||
|
||||
const range = getRange();
|
||||
|
||||
const quickRanges = [
|
||||
{ label: 'Hoje', getValue: () => ({ start: new Date(), end: new Date() }) },
|
||||
{ label: 'Últimos 7 dias', getValue: () => ({ start: subDays(new Date(), 7), end: new Date() }) },
|
||||
{ label: 'Últimos 14 dias', getValue: () => ({ start: subDays(new Date(), 14), end: new Date() }) },
|
||||
{ label: 'Últimos 30 dias', getValue: () => ({ start: subDays(new Date(), 30), end: new Date() }) },
|
||||
{ label: 'Este Mês', getValue: () => ({ start: startOfMonth(new Date()), end: endOfMonth(new Date()) }) },
|
||||
];
|
||||
|
||||
const handleDateClick = (day: Date) => {
|
||||
if (mode === 'single') {
|
||||
onChange(day);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!range.start || (range.start && range.end)) {
|
||||
onChange({ start: day, end: null });
|
||||
} else {
|
||||
if (isBefore(day, range.start)) {
|
||||
onChange({ start: day, end: range.start });
|
||||
} else {
|
||||
onChange({ start: range.start, end: day });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isInRange = (day: Date) => {
|
||||
if (range.start && range.end) {
|
||||
return isWithinInterval(day, { start: range.start, end: range.end });
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (!range.start) return placeholder || (mode === 'single' ? "Selecionar data" : "Selecionar período");
|
||||
if (mode === 'single') return format(range.start, "dd/MM/yyyy", { locale: ptBR });
|
||||
if (!range.end || isSameDay(range.start, range.end)) return format(range.start, "dd MMM yyyy", { locale: ptBR });
|
||||
return `${format(range.start, "dd MMM", { locale: ptBR })} - ${format(range.end, "dd MMM yyyy", { locale: ptBR })}`;
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange(mode === 'single' ? null : { start: null, end: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover className={`relative ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<PopoverButton
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold transition-all outline-none border rounded-xl group
|
||||
${buttonClassName || 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400'}
|
||||
focus:border-zinc-400 dark:focus:border-zinc-500
|
||||
`}
|
||||
>
|
||||
<CalendarIcon className="w-4.5 h-4.5 text-zinc-400 group-hover:text-brand-500 transition-colors shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{displayValue()}
|
||||
</span>
|
||||
{range.start && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</PopoverButton>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel
|
||||
anchor="bottom end"
|
||||
className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-xl overflow-hidden outline-none [--anchor-gap:8px] min-w-[480px]"
|
||||
>
|
||||
{/* Filtros Rápidos (Apenas para Range) */}
|
||||
{mode === 'range' && (
|
||||
<div className="w-48 bg-zinc-50/80 dark:bg-zinc-900/80 border-r border-zinc-100 dark:border-zinc-800 p-4 space-y-1.5">
|
||||
<div className="flex items-center gap-2 px-1 py-1 mb-2">
|
||||
<ClockIcon className="w-3.5 h-3.5 text-zinc-400" />
|
||||
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.1em]">Atalhos</p>
|
||||
</div>
|
||||
{quickRanges.map((r) => (
|
||||
<button
|
||||
key={r.label}
|
||||
type="button"
|
||||
onClick={() => onChange(r.getValue())}
|
||||
className="w-full px-3 py-2 text-left text-[11px] font-bold text-zinc-600 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800 hover:text-brand-600 dark:hover:text-brand-400 rounded-xl transition-all border border-transparent hover:border-zinc-100 dark:hover:border-zinc-700 hover:shadow-sm"
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-4 mt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ start: null, end: null })}
|
||||
className="w-full px-3 py-2 text-left text-[11px] font-bold text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-all"
|
||||
>
|
||||
Limpar Filtro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendário */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white capitalize">
|
||||
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
||||
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4 text-zinc-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
||||
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4 text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-2">
|
||||
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
|
||||
<div key={idx} className="text-center text-[10px] font-bold text-zinc-400 py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{eachDayOfInterval({
|
||||
start: startOfWeek(startOfMonth(currentMonth)),
|
||||
end: endOfWeek(endOfMonth(currentMonth))
|
||||
}).map((day, idx) => {
|
||||
const isStart = range.start && isSameDay(day, range.start);
|
||||
const isEnd = range.end && isSameDay(day, range.end);
|
||||
const isSelected = isStart || isEnd;
|
||||
const isRange = isInRange(day);
|
||||
const isCurrentMonth = isSameMonth(day, startOfMonth(currentMonth));
|
||||
const isTodayDate = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleDateClick(day)}
|
||||
className={`
|
||||
relative py-2.5 text-[11px] transition-all outline-none flex items-center justify-center font-bold h-10 w-10
|
||||
${!isCurrentMonth ? 'text-zinc-300 dark:text-zinc-600' : 'text-zinc-900 dark:text-zinc-100'}
|
||||
${isSelected ? 'bg-brand-500 text-white rounded-xl z-10 scale-105 shadow-lg shadow-brand-500/20' : ''}
|
||||
${isRange && !isSelected && mode === 'range' ? 'bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400' : ''}
|
||||
${!isSelected ? 'hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:rounded-xl' : ''}
|
||||
${isTodayDate && !isSelected ? 'text-brand-600 ring-1 ring-brand-100 dark:ring-brand-500/30 rounded-xl' : ''}
|
||||
`}
|
||||
>
|
||||
<span>{format(day, "d")}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
${isPassword || rightIcon ? "pr-11" : ""}
|
||||
${error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-4 focus:ring-red-500/10"
|
||||
: "border-gray-200 dark:border-gray-700 focus:border-brand-500 focus:ring-4 focus:ring-brand-500/10"
|
||||
: "border-zinc-200 dark:border-zinc-700 focus:border-zinc-400 dark:focus:border-zinc-500 focus:ring-0"
|
||||
}
|
||||
outline-none
|
||||
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
|
||||
|
||||
76
front-end-agency/components/ui/PageHeader.tsx
Normal file
76
front-end-agency/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Button from "./Button";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
primaryAction?: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
secondaryAction?: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
children
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight truncate">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={secondaryAction.onClick}
|
||||
className="bg-white dark:bg-zinc-900"
|
||||
>
|
||||
{secondaryAction.icon && (
|
||||
<span className="mr-2">{secondaryAction.icon}</span>
|
||||
)}
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={primaryAction.onClick}
|
||||
isLoading={primaryAction.isLoading}
|
||||
className="shadow-lg shadow-brand-500/20"
|
||||
style={{ background: 'var(--gradient)' }}
|
||||
>
|
||||
{primaryAction.icon && !primaryAction.isLoading && (
|
||||
<span className="mr-2">{primaryAction.icon}</span>
|
||||
)}
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
front-end-agency/components/ui/StatsCard.tsx
Normal file
67
front-end-agency/components/ui/StatsCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ArrowTrendingUpIcon as TrendingUpIcon, ArrowTrendingDownIcon as TrendingDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: ReactNode;
|
||||
trend?: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
type: "up" | "down" | "neutral";
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function StatsCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
description
|
||||
}: StatsCardProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-6 transition-all group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{title}
|
||||
</p>
|
||||
<h3 className="mt-1 text-2xl font-bold text-zinc-900 dark:text-white group-hover:text-brand-500 transition-colors">
|
||||
{value}
|
||||
</h3>
|
||||
|
||||
{trend && (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<div className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold ${trend.type === 'up'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400'
|
||||
: trend.type === 'down'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400'
|
||||
}`}>
|
||||
{trend.type === 'up' && <TrendingUpIcon className="w-3 h-3" />}
|
||||
{trend.type === 'down' && <TrendingDownIcon className="w-3 h-3" />}
|
||||
{trend.value}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-500">
|
||||
{trend.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{description && !trend && (
|
||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-zinc-50 dark:bg-zinc-800 rounded-xl text-zinc-500 dark:text-zinc-400 group-hover:bg-brand-50 dark:group-hover:bg-brand-900/10 group-hover:text-brand-500 transition-all">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
front-end-agency/components/ui/Tabs.tsx
Normal file
69
front-end-agency/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[];
|
||||
defaultIndex?: number;
|
||||
onChange?: (index: number) => void;
|
||||
className?: string;
|
||||
variant?: 'pills' | 'underline';
|
||||
}
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
items,
|
||||
defaultIndex = 0,
|
||||
onChange,
|
||||
className = "",
|
||||
variant = 'pills'
|
||||
}: TabsProps) {
|
||||
return (
|
||||
<TabGroup defaultIndex={defaultIndex} onChange={onChange} className={className}>
|
||||
<TabList className={classNames(
|
||||
'flex space-x-1 p-1 mb-6',
|
||||
variant === 'pills' ? 'bg-zinc-100 dark:bg-zinc-800/50 rounded-xl' : 'border-b border-zinc-200 dark:border-zinc-800 bg-transparent'
|
||||
)}>
|
||||
{items.map((item, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
className={({ selected }) =>
|
||||
classNames(
|
||||
'flex items-center justify-center gap-2 py-2.5 text-sm font-bold transition-all outline-none rounded-lg flex-1 cursor-pointer',
|
||||
variant === 'pills'
|
||||
? selected
|
||||
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm ring-1 ring-black/5'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-white/50 dark:hover:bg-zinc-700/30'
|
||||
: selected
|
||||
? 'border-b-2 border-brand-500 text-brand-500 rounded-none'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 border-b-2 border-transparent rounded-none'
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
|
||||
{item.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{items.map((item, index) => (
|
||||
<TabPanel key={index} className="outline-none">
|
||||
{item.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,13 @@ export { default as Checkbox } from "./Checkbox";
|
||||
export { default as Select } from "./Select";
|
||||
export { default as SearchableSelect } from "./SearchableSelect";
|
||||
export { default as Dialog } from "./Dialog";
|
||||
export { default as PageHeader } from "./PageHeader";
|
||||
export { default as Card } from "./Card";
|
||||
export { default as StatsCard } from "./StatsCard";
|
||||
export { default as Tabs } from "./Tabs";
|
||||
export { default as DataTable } from "./DataTable";
|
||||
export { default as DatePicker } from "./DatePicker";
|
||||
export { default as CustomSelect } from "./CustomSelect";
|
||||
export { default as Badge } from "./Badge";
|
||||
export { default as BulkActionBar } from "./BulkActionBar";
|
||||
export { default as ConfirmDialog } from "../layout/ConfirmDialog";
|
||||
|
||||
Reference in New Issue
Block a user