beetask/frontend/src/pages/DashboardV2Page.tsx

499 lines
18 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Plus,
RefreshCw,
RotateCcw,
BarChart3,
Layers,
LayoutDashboard,
AlertCircle,
Check,
User,
Users,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext'
import { taskService } from '../services/taskService'
import { projectService } from '../services/projectService'
import { categoryService } from '../services/categoryService'
import type { Task, Project, Category } from '../types/task'
import {
getLocalPreferences,
syncPreferencesFromBackend,
savePreferencesToBackend,
DEFAULT_V2_CARDS,
type DashboardV2Card,
} from '../lib/userPreferences'
import { getUserWorkGoal, type WorkGoal } from '../lib/userGoals'
import { BiUniformCard } from '../components/dashboard/BiUniformCard'
import { CreateBiCardModal } from '../components/dashboard/CreateBiCardModal'
export const DashboardV2Page: React.FC = () => {
const { user, activeOrg } = useAuth()
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Work goal
const [workGoal, setWorkGoal] = useState<WorkGoal>(() => getUserWorkGoal(user?.email))
// Dashboard v2 BI Cards State
const [biCards, setBiCards] = useState<DashboardV2Card[]>(() =>
getLocalPreferences(user?.email).dashboardV2?.cards || DEFAULT_V2_CARDS
)
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null)
const [saveToast, setSaveToast] = useState(false)
// Scope Toggle State (Individual vs Equipe)
const [searchParams, setSearchParams] = useSearchParams()
const initialScope =
(searchParams.get('scope') as 'mine' | 'team') ||
(localStorage.getItem('beetask_bi_scope') as 'mine' | 'team') ||
'team'
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
const handleScopeChange = (newScope: 'mine' | 'team') => {
setViewScope(newScope)
try {
localStorage.setItem('beetask_bi_scope', newScope)
} catch (_) {}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('scope', newScope)
return next
},
{ replace: true }
)
}
const activeOrgId = activeOrg?.id
// Load backend data
const loadData = useCallback(async () => {
if (!activeOrgId) return
try {
setRefreshing(true)
const [tasksRes, projsRes, catsRes] = await Promise.allSettled([
taskService.listTasks({ organizationId: activeOrgId }),
projectService.listProjects(activeOrgId),
categoryService.listCategories(activeOrgId),
])
const loadedTasks = tasksRes.status === 'fulfilled' ? tasksRes.value || [] : []
setTasks(loadedTasks)
syncWithTasks(loadedTasks)
if (projsRes.status === 'fulfilled') {
setProjects(projsRes.value || [])
}
if (catsRes.status === 'fulfilled') {
setCategories(catsRes.value || [])
}
} catch (err) {
console.error(err)
setError('Falha ao carregar dados do workspace.')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [activeOrgId, syncWithTasks])
useEffect(() => {
loadData()
}, [loadData])
// Sync preferences on load & user change
useEffect(() => {
if (user?.email) {
setWorkGoal(getUserWorkGoal(user.email))
setBiCards(getLocalPreferences(user.email).dashboardV2?.cards || DEFAULT_V2_CARDS)
syncPreferencesFromBackend(user.email).then((prefs) => {
if (prefs?.dashboardV2?.cards) {
setBiCards(prefs.dashboardV2.cards)
}
})
}
}, [user?.email])
// Persist helper
const persistBiCards = useCallback(
async (newCards: DashboardV2Card[]) => {
if (!user?.email) return
setBiCards(newCards)
try {
await savePreferencesToBackend(user.email, {
dashboardV2: { cards: newCards },
workGoal,
})
setSaveToast(true)
setTimeout(() => setSaveToast(false), 1500)
} catch (err) {
console.error('Failed to save BI cards to backend:', err)
}
},
[user?.email, workGoal]
)
// Add or Edit Card
const handleSaveCard = (card: DashboardV2Card) => {
const exists = biCards.some((c) => c.id === card.id)
let next: DashboardV2Card[]
if (exists) {
next = biCards.map((c) => (c.id === card.id ? card : c))
} else {
next = [...biCards, card]
}
persistBiCards(next)
}
// Delete Card
const handleDeleteCard = (cardId: string) => {
const next = biCards.filter((c) => c.id !== cardId)
persistBiCards(next)
}
// Drag and Drop reordering state & handlers
const [draggedCardId, setDraggedCardId] = useState<string | null>(null)
const [dragOverCardId, setDragOverCardId] = useState<string | null>(null)
const handleSwapCards = (idA: string, idB: string) => {
if (idA === idB) return
const indexA = biCards.findIndex((c) => c.id === idA)
const indexB = biCards.findIndex((c) => c.id === idB)
if (indexA === -1 || indexB === -1) return
const next = [...biCards]
const temp = next[indexA]
next[indexA] = next[indexB]
next[indexB] = temp
persistBiCards(next)
}
const handleDragStart = (e: React.DragEvent, id: string) => {
e.dataTransfer.setData('text/plain', id)
e.dataTransfer.effectAllowed = 'move'
setDraggedCardId(id)
}
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverCardId !== id) {
setDragOverCardId(id)
}
}
const handleDragLeave = (_e: React.DragEvent, id: string) => {
if (dragOverCardId === id) {
setDragOverCardId(null)
}
}
const handleDrop = (e: React.DragEvent, targetId: string) => {
e.preventDefault()
const sourceId = (e.dataTransfer.getData('text/plain') as string) || draggedCardId
if (sourceId && sourceId !== targetId) {
handleSwapCards(sourceId, targetId)
}
setDraggedCardId(null)
setDragOverCardId(null)
}
const handleDragEnd = () => {
setDraggedCardId(null)
setDragOverCardId(null)
}
// Move Card Left/Right
const handleMoveCard = (cardId: string, direction: 'prev' | 'next') => {
const currentIdx = biCards.findIndex((c) => c.id === cardId)
if (currentIdx === -1) return
const targetIdx = direction === 'prev' ? currentIdx - 1 : currentIdx + 1
if (targetIdx < 0 || targetIdx >= biCards.length) return
const next = [...biCards]
const temp = next[currentIdx]
next[currentIdx] = next[targetIdx]
next[targetIdx] = temp
persistBiCards(next)
}
// Reset to default cards
const handleResetDefault = () => {
persistBiCards(DEFAULT_V2_CARDS)
}
// Clear all cards to start 100% from scratch
const handleClearAll = () => {
persistBiCards([])
}
// Timer handler
const handleToggleTimer = async (task: Task) => {
try {
if (activeTask?.id === task.id) {
const stopped = await stopTimer(task)
if (stopped) {
setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
}
} else {
const started = await startTimer(task)
setTasks((prev) => prev.map((t) => (t.id === started.id ? started : t)))
}
} catch (err) {
console.error(err)
setError('Erro ao acionar cronômetro.')
}
}
const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor'
return (
<div className="space-y-8 max-w-7xl mx-auto pb-16">
{/* Top Banner with Navigation Tabs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-5 pb-2">
<div className="space-y-2">
{/* Tabs: Visão Geral vs BI v2 */}
<div className="flex items-center gap-2">
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-border-soft bg-surface hover:bg-surface-raised text-xs font-semibold text-muted hover:text-foreground transition-all shadow-xs"
>
<LayoutDashboard className="w-3.5 h-3.5" />
<span>Visão Geral</span>
</Link>
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-brand/10 border border-brand/25 text-xs font-bold text-brand shadow-xs">
<BarChart3 className="w-3.5 h-3.5 text-brand" />
<span>Painel BI v2 (Modular)</span>
<span className="flex h-1.5 w-1.5 rounded-full bg-accent animate-pulse" />
</div>
</div>
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-foreground leading-tight">
BI Customizável, {firstName}!{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand via-brand-strong to-accent">
Monte seu painel.
</span>
</h1>
<p className="text-xs sm:text-sm text-muted max-w-2xl leading-relaxed">
Crie e combine indicadores do zero em cards uniformes. Filtre por projeto, período e status como em um BI corporativo.
</p>
</div>
{/* Action Controls */}
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={loadData}
disabled={refreshing}
title="Recarregar dados"
className="p-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-xs disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
</button>
{biCards.length > 0 && (
<button
type="button"
onClick={handleClearAll}
title="Limpar todos os cards para começar do zero"
className="px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-xs font-bold text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-xs"
>
Limpar Todos
</button>
)}
<button
type="button"
onClick={handleResetDefault}
title="Restaurar conjunto padrão de cards de BI"
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-xs font-bold text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-xs"
>
<RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Restaurar Padrão</span>
</button>
<button
type="button"
onClick={() => {
setEditingCard(null)
setIsModalOpen(true)
}}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white font-bold text-xs shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]"
>
<Plus className="w-4 h-4" />
<span>Novo Card de BI</span>
</button>
</div>
</div>
{/* Save Toast Feedback */}
{saveToast && (
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-700 dark:text-emerald-400 text-xs font-bold animate-in fade-in slide-in-from-top-2 duration-200">
<Check className="w-4 h-4" />
<span>Configuração salva no banco de dados!</span>
</div>
)}
{error && (
<div className="flex items-center justify-between p-4 rounded-2xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs backdrop-blur-xs">
<div className="flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-rose-400" />
<span>{error}</span>
</div>
<button
type="button"
onClick={() => setError(null)}
className="text-rose-400 hover:text-rose-200 text-xs font-bold"
>
Fechar
</button>
</div>
)}
{/* Scope Selector: Individual vs Equipe */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 sm:p-4 rounded-2xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
Escopo do Painel:
</span>
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
<button
type="button"
onClick={() => handleScopeChange('mine')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewScope === 'mine'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Minha Visão (Individual)</span>
</button>
<button
type="button"
onClick={() => handleScopeChange('team')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewScope === 'team'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Visão da Equipe (Workspace)</span>
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
<span className="text-[11px] truncate">
{viewScope === 'mine'
? 'Exibindo cards com dados das suas tarefas atribuídas'
: 'Exibindo cards com métricas consolidadas de todo o workspace'}
</span>
</div>
</div>
{/* Uniform Modular Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="h-[260px] rounded-3xl border border-border-soft bg-surface/60 backdrop-blur-md animate-pulse p-5"
/>
))}
</div>
) : biCards.length === 0 ? (
<div className="text-center py-24 rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md p-8 space-y-4">
<Layers className="w-12 h-12 text-muted mx-auto mb-2 opacity-50" />
<h3 className="text-lg font-bold text-foreground">Seu Painel BI está vazio</h3>
<p className="text-xs text-muted max-w-md mx-auto">
Você pode criar cards totalmente customizados do zero escolhendo métricas, filtros de projeto, períodos e status.
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={handleResetDefault}
className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface text-foreground text-xs font-bold hover:bg-surface-raised transition-all"
>
Carregar Cards de Exemplo
</button>
<button
type="button"
onClick={() => {
setEditingCard(null)
setIsModalOpen(true)
}}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-strong shadow-lg shadow-brand/20 transition-all"
>
<Plus className="w-4 h-4" />
<span>Criar Primeiro Card</span>
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{biCards.map((card, idx) => (
<BiUniformCard
key={card.id}
card={card}
index={idx}
totalCards={biCards.length}
tasks={tasks}
projects={projects}
categories={categories}
activeTask={activeTask}
activeSeconds={activeSeconds}
workGoal={workGoal}
globalScope={viewScope}
isBeingDragged={draggedCardId === card.id}
isDragOver={dragOverCardId === card.id}
onToggleTimer={handleToggleTimer}
onMoveCard={handleMoveCard}
onEditCard={(c) => {
setEditingCard(c)
setIsModalOpen(true)
}}
onDeleteCard={handleDeleteCard}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
</div>
)}
{/* Create / Edit BI Card Modal */}
<CreateBiCardModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingCard(null)
}}
onSave={handleSaveCard}
editingCard={editingCard}
projects={projects}
categories={categories}
/>
</div>
)
}