beetask/frontend/src/components/dashboard/CreateBiCardModal.tsx

413 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import {
X,
Sparkles,
BarChart3,
Clock,
CheckCircle2,
PieChart,
Target,
Flame,
ListOrdered,
Layers,
Tag,
} from 'lucide-react'
import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
import type { Project, Category } from '../../types/task'
import { CustomSelect } from '../ui/Select'
interface CreateBiCardModalProps {
isOpen: boolean
onClose: () => void
onSave: (card: DashboardV2Card) => void
editingCard?: DashboardV2Card | null
projects: Project[]
categories: Category[]
}
const METRIC_OPTIONS: {
type: BiMetricType
label: string
desc: string
icon: React.FC<{ className?: string }>
}[] = [
{
type: 'HOURS_TRACKED',
label: 'Horas Dedicadas',
desc: 'Total de tempo cronometrado nas tarefas com os filtros selecionados.',
icon: Clock,
},
{
type: 'TASK_COUNT',
label: 'Quantidade de Tarefas',
desc: 'Total numérico de tarefas criadas ou filtradas.',
icon: Layers,
},
{
type: 'COMPLETION_RATE',
label: 'Taxa de Conclusão (%)',
desc: 'Percentual de demandas entregues em relação ao total.',
icon: CheckCircle2,
},
{
type: 'PROJECT_BREAKDOWN',
label: 'Distribuição por Projeto',
desc: 'Comparativo visual de horas e tarefas por iniciativa.',
icon: BarChart3,
},
{
type: 'CATEGORY_BREAKDOWN',
label: 'Distribuição por Categoria',
desc: 'Divisão de horas dedicadas por categoria de tarefa.',
icon: Tag,
},
{
type: 'STATUS_DISTRIBUTION',
label: 'Distribuição por Status',
desc: 'Divisão entre A Fazer, Em Andamento e Concluído.',
icon: PieChart,
},
{
type: 'WORK_GOAL',
label: 'Meta de Trabalho & Jornada',
desc: 'Progresso frente à meta diária, semanal ou mensal de horas.',
icon: Target,
},
{
type: 'ACTIVE_TIMER',
label: 'Cronômetro & Foco Atual',
desc: 'Monitor do tempo ativo com botão de controle direto no card.',
icon: Flame,
},
{
type: 'TOP_TASKS',
label: 'Demandas Mais Dedicadas',
desc: 'Top 3 tarefas com maior volume de horas acumuladas.',
icon: ListOrdered,
},
]
const COLOR_OPTIONS: { id: DashboardV2Card['colorTheme']; label: string; bg: string }[] = [
{ id: 'brand', label: 'Teal (Brand)', bg: 'bg-teal-500' },
{ id: 'emerald', label: 'Esmeralda', bg: 'bg-emerald-500' },
{ id: 'accent', label: 'Âmbar', bg: 'bg-amber-500' },
{ id: 'indigo', label: 'Índigo', bg: 'bg-indigo-500' },
{ id: 'cyan', label: 'Ciano', bg: 'bg-cyan-500' },
{ id: 'rose', label: 'Rose', bg: 'bg-rose-500' },
]
export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
isOpen,
onClose,
onSave,
editingCard,
projects,
categories,
}) => {
const [title, setTitle] = useState('')
const [metricType, setMetricType] = useState<BiMetricType>('HOURS_TRACKED')
const [period, setPeriod] = useState<BiPeriodFilter>('WEEK')
const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
const [scope, setScope] = useState<BiCardScope>('INHERIT')
useEffect(() => {
if (editingCard) {
setTitle(editingCard.title)
setMetricType(editingCard.metricType)
setPeriod(editingCard.period)
setProjectId(editingCard.projectId)
setCategoryId(editingCard.categoryId)
setStatusFilter(editingCard.statusFilter || 'ALL')
setColorTheme(editingCard.colorTheme || 'brand')
setScope(editingCard.scope || 'INHERIT')
} else {
setTitle('')
setMetricType('HOURS_TRACKED')
setPeriod('WEEK')
setProjectId(undefined)
setCategoryId(undefined)
setStatusFilter('ALL')
setColorTheme('brand')
setScope('INHERIT')
}
}, [editingCard, isOpen])
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const finalTitle = title.trim() || getDefaultTitleForMetric(metricType)
const card: DashboardV2Card = {
id: editingCard?.id || `card-${Date.now()}`,
title: finalTitle,
metricType,
period,
projectId,
categoryId,
statusFilter,
colorTheme,
scope,
}
onSave(card)
onClose()
}
function getDefaultTitleForMetric(type: BiMetricType): string {
const found = METRIC_OPTIONS.find((m) => m.type === type)
return found ? found.label : 'Novo Indicador'
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh] animate-in zoom-in-95 duration-200"
role="dialog"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-soft">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
<Sparkles className="w-5 h-5 text-accent" />
</div>
<div>
<h2 className="text-base font-extrabold text-foreground">
{editingCard ? 'Editar Card de BI' : 'Criar Card de BI do Zero'}
</h2>
<p className="text-xs text-muted">
Combine métricas, filtros de projeto e períodos no mesmo card.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-5 flex-1">
{/* Card Title */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
Título do Card <span className="text-muted font-normal">(Opcional)</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={getDefaultTitleForMetric(metricType)}
className="w-full px-4 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs"
/>
</div>
{/* Metric Type Selector */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
Tipo de Indicador / Métrica
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{METRIC_OPTIONS.map((opt) => {
const IconComponent = opt.icon
const isSelected = metricType === opt.type
return (
<button
key={opt.type}
type="button"
onClick={() => setMetricType(opt.type)}
className={`p-3 rounded-2xl border text-left transition-all flex items-start gap-3 ${
isSelected
? 'border-brand bg-brand/10 ring-1 ring-brand/30 shadow-xs'
: 'border-border-soft bg-surface-raised/60 hover:bg-surface-raised hover:border-brand/30'
}`}
>
<div
className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 border ${
isSelected
? 'bg-brand text-white border-brand'
: 'bg-surface text-muted border-border-soft'
}`}
>
<IconComponent className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold text-foreground leading-tight">
{opt.label}
</p>
<p className="text-[10px] text-muted line-clamp-2 mt-0.5">
{opt.desc}
</p>
</div>
</button>
)
})}
</div>
</div>
{/* Combined Filters (BI Dimensions) */}
<div className="pt-2 border-t border-border-soft space-y-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted">
Filtros Combináveis (Dimensões de BI)
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Period Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Período</label>
<CustomSelect
value={period}
onChange={(val) => setPeriod(val as BiPeriodFilter)}
options={[
{ value: 'INHERIT', label: '🔗 Seguir Período da Página (Recomendado)' },
{ value: 'TODAY', label: '📅 Apenas Hoje' },
{ value: 'YESTERDAY', label: 'Ontem' },
{ value: 'WEEK', label: 'Esta Semana' },
{ value: 'LAST_WEEK', label: 'Semana Passada' },
{ value: 'MONTH', label: 'Este Mês' },
{ value: 'LAST_MONTH', label: 'Mês Passado' },
{ value: 'LAST_3_MONTHS', label: 'Últimos 3 Meses (Trimestre)' },
{ value: 'LAST_6_MONTHS', label: 'Últimos 6 Meses (Semestre)' },
{ value: 'YEAR', label: 'Este Ano' },
{ value: 'ALL', label: 'Todo o Histórico' },
]}
/>
</div>
{/* Status Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Filtro por Status</label>
<CustomSelect
value={statusFilter}
onChange={(val) => setStatusFilter(val as any)}
options={[
{ value: 'ALL', label: 'Todos os Status' },
{ value: 'COMPLETED', label: 'Apenas Concluídas', color: '#10b981' },
{ value: 'IN_PROGRESS', label: 'Apenas Em Andamento', color: '#14b8a6' },
{ value: 'NOT_STARTED', label: 'Apenas A Fazer', color: '#71717a' },
]}
/>
</div>
{/* Project Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Iniciativa / Projeto</label>
<CustomSelect
value={projectId ?? 0}
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Todos os Projetos"
options={[
{ value: 0, label: 'Todos os Projetos' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color,
})),
]}
/>
</div>
{/* Category Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Categoria / Tag</label>
<CustomSelect
value={categoryId ?? 0}
onChange={(val) => setCategoryId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Todas as Categorias"
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
{/* Data Scope Filter */}
<div className="space-y-1.5 sm:col-span-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Escopo dos Dados (Atribuição)</span>
<span className="text-[11px] text-muted font-normal">Individual vs Equipe</span>
</label>
<CustomSelect
value={scope}
onChange={(val) => setScope(val as BiCardScope)}
options={[
{
value: 'INHERIT',
label: '🔗 Seguir filtro global da página (Padrão)',
description: 'Acompanha a alternância da tela',
},
{
value: 'MINE',
label: '👤 Sempre Apenas Minhas Tarefas (Individual)',
description: 'Fixo nas suas demandas',
},
{
value: 'TEAM',
label: '👥 Sempre Toda a Equipe (Workspace Geral)',
description: 'Fixo em todo o time',
},
]}
/>
</div>
</div>
</div>
{/* Color Theme Selector */}
<div className="pt-2 border-t border-border-soft space-y-2">
<label className="text-xs font-bold text-foreground block">
Cor de Destaque do Card
</label>
<div className="flex items-center gap-3 flex-wrap">
{COLOR_OPTIONS.map((c) => {
const isSelected = colorTheme === c.id
return (
<button
key={c.id}
type="button"
onClick={() => setColorTheme(c.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-semibold transition-all ${
isSelected
? 'border-brand bg-surface shadow-xs ring-2 ring-brand/40 text-foreground font-bold'
: 'border-border-soft bg-surface-raised text-muted hover:text-foreground'
}`}
>
<span className={`w-3 h-3 rounded-full ${c.bg}`} />
<span>{c.label}</span>
</button>
)
})}
</div>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-border-soft bg-surface-muted/30">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-foreground text-xs font-bold transition-colors shadow-xs"
>
Cancelar
</button>
<button
type="button"
onClick={handleSubmit}
className="px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white text-xs font-bold shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]"
>
{editingCard ? 'Salvar Alterações' : 'Adicionar ao Painel'}
</button>
</div>
</div>
</div>
)
}