feat(dashboard): add comprehensive period selector and time tracking to dashboard and dashboard v2
This commit is contained in:
parent
977c7ffd7a
commit
49d010eb71
7 changed files with 809 additions and 70 deletions
|
|
@ -22,17 +22,18 @@ import {
|
||||||
import type { DashboardV2Card } from '../../lib/userPreferences'
|
import type { DashboardV2Card } from '../../lib/userPreferences'
|
||||||
import type { Task, Project, Category } from '../../types/task'
|
import type { Task, Project, Category } from '../../types/task'
|
||||||
import {
|
import {
|
||||||
calculateTaskTotalSeconds,
|
|
||||||
formatSecondsToHuman,
|
formatSecondsToHuman,
|
||||||
formatSecondsToTimer,
|
formatSecondsToTimer,
|
||||||
} from '../../lib/timeUtils'
|
} from '../../lib/timeUtils'
|
||||||
import {
|
import {
|
||||||
calculateUserPeriodWorkedSeconds,
|
calculateUserPeriodWorkedSeconds,
|
||||||
isSameDay,
|
|
||||||
isSameWeek,
|
|
||||||
isSameMonth,
|
|
||||||
type WorkGoal,
|
type WorkGoal,
|
||||||
} from '../../lib/userGoals'
|
} from '../../lib/userGoals'
|
||||||
|
import {
|
||||||
|
type PeriodPreset,
|
||||||
|
getDateRangeForPreset,
|
||||||
|
calculateTaskSecondsInRange,
|
||||||
|
} from '../../lib/periodUtils'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
interface BiUniformCardProps {
|
interface BiUniformCardProps {
|
||||||
|
|
@ -46,6 +47,9 @@ interface BiUniformCardProps {
|
||||||
activeSeconds: number
|
activeSeconds: number
|
||||||
workGoal: WorkGoal
|
workGoal: WorkGoal
|
||||||
globalScope?: 'mine' | 'team'
|
globalScope?: 'mine' | 'team'
|
||||||
|
globalPeriod?: PeriodPreset
|
||||||
|
globalCustomStart?: string
|
||||||
|
globalCustomEnd?: string
|
||||||
isBeingDragged?: boolean
|
isBeingDragged?: boolean
|
||||||
isDragOver?: boolean
|
isDragOver?: boolean
|
||||||
onToggleTimer: (task: Task) => void
|
onToggleTimer: (task: Task) => void
|
||||||
|
|
@ -124,6 +128,9 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
activeSeconds,
|
activeSeconds,
|
||||||
workGoal,
|
workGoal,
|
||||||
globalScope = 'team',
|
globalScope = 'team',
|
||||||
|
globalPeriod = 'this_month',
|
||||||
|
globalCustomStart,
|
||||||
|
globalCustomEnd,
|
||||||
isBeingDragged = false,
|
isBeingDragged = false,
|
||||||
isDragOver = false,
|
isDragOver = false,
|
||||||
onToggleTimer,
|
onToggleTimer,
|
||||||
|
|
@ -138,7 +145,6 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const theme = THEME_STYLES[card.colorTheme || 'brand']
|
const theme = THEME_STYLES[card.colorTheme || 'brand']
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
// Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope
|
// Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope
|
||||||
const effectiveScope: 'mine' | 'team' =
|
const effectiveScope: 'mine' | 'team' =
|
||||||
|
|
@ -148,6 +154,28 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
: 'team'
|
: 'team'
|
||||||
: globalScope
|
: globalScope
|
||||||
|
|
||||||
|
// Resolve period: Card's specific period takes precedence, otherwise fallback to page's globalPeriod
|
||||||
|
const effectivePreset: PeriodPreset = (() => {
|
||||||
|
if (!card.period || card.period === 'INHERIT') {
|
||||||
|
return globalPeriod || 'this_month'
|
||||||
|
}
|
||||||
|
switch (card.period) {
|
||||||
|
case 'TODAY': return 'today'
|
||||||
|
case 'YESTERDAY': return 'yesterday'
|
||||||
|
case 'WEEK': return 'this_week'
|
||||||
|
case 'LAST_WEEK': return 'last_week'
|
||||||
|
case 'MONTH': return 'this_month'
|
||||||
|
case 'LAST_MONTH': return 'last_month'
|
||||||
|
case 'LAST_3_MONTHS': return 'last_3_months'
|
||||||
|
case 'LAST_6_MONTHS': return 'last_6_months'
|
||||||
|
case 'YEAR': return 'this_year'
|
||||||
|
case 'ALL': return 'all'
|
||||||
|
default: return 'all'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const currentRange = getDateRangeForPreset(effectivePreset, globalCustomStart, globalCustomEnd)
|
||||||
|
|
||||||
// 1. Filter Tasks based on scope and card's combined dimensions
|
// 1. Filter Tasks based on scope and card's combined dimensions
|
||||||
const filteredTasks = tasks.filter((t) => {
|
const filteredTasks = tasks.filter((t) => {
|
||||||
// Scope filter (if 'mine', only user's tasks)
|
// Scope filter (if 'mine', only user's tasks)
|
||||||
|
|
@ -168,46 +196,39 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Period-aware seconds calculation
|
// Period-aware seconds calculation using exact range intersection
|
||||||
const periodSeconds = filteredTasks.reduce((acc, t) => {
|
const periodSeconds = filteredTasks.reduce((acc, t) => {
|
||||||
let taskSec = 0
|
return acc + calculateTaskSecondsInRange(
|
||||||
if (card.period === 'ALL') {
|
t,
|
||||||
taskSec = calculateTaskTotalSeconds(t)
|
currentRange.startDate,
|
||||||
} else {
|
currentRange.endDate,
|
||||||
for (const item of t.history || []) {
|
activeTask?.id,
|
||||||
if (!item.startAt) continue
|
activeSeconds
|
||||||
const itemDate = new Date(item.startAt)
|
)
|
||||||
let matches = false
|
|
||||||
if (card.period === 'TODAY') matches = isSameDay(itemDate, now)
|
|
||||||
else if (card.period === 'WEEK') matches = isSameWeek(itemDate, now)
|
|
||||||
else if (card.period === 'MONTH') matches = isSameMonth(itemDate, now)
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
const startMs = itemDate.getTime()
|
|
||||||
const endMs = item.endAt ? new Date(item.endAt).getTime() : startMs
|
|
||||||
const duration = Math.max(0, Math.floor((endMs - startMs) / 1000))
|
|
||||||
taskSec += duration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTask?.id === t.id && card.period !== 'ALL') {
|
|
||||||
taskSec += activeSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc + taskSec
|
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// Associated names
|
// Associated names
|
||||||
const projectName = projects.find((p) => p.id === card.projectId)?.name
|
const projectName = projects.find((p) => p.id === card.projectId)?.name
|
||||||
const categoryName = categories.find((c) => c.id === card.categoryId)?.name
|
const categoryName = categories.find((c) => c.id === card.categoryId)?.name
|
||||||
|
|
||||||
const periodLabels: Record<string, string> = {
|
const periodBadgeLabel = (() => {
|
||||||
ALL: 'Total',
|
if (!card.period || card.period === 'INHERIT') {
|
||||||
TODAY: 'Hoje',
|
return currentRange.label
|
||||||
WEEK: 'Esta Semana',
|
|
||||||
MONTH: 'Este Mês',
|
|
||||||
}
|
}
|
||||||
|
switch (card.period) {
|
||||||
|
case 'TODAY': return 'Hoje'
|
||||||
|
case 'YESTERDAY': return 'Ontem'
|
||||||
|
case 'WEEK': return 'Esta Semana'
|
||||||
|
case 'LAST_WEEK': return 'Semana Passada'
|
||||||
|
case 'MONTH': return 'Este Mês'
|
||||||
|
case 'LAST_MONTH': return 'Mês Passado'
|
||||||
|
case 'LAST_3_MONTHS': return 'Últimos 3 Meses'
|
||||||
|
case 'LAST_6_MONTHS': return 'Últimos 6 Meses'
|
||||||
|
case 'YEAR': return 'Este Ano'
|
||||||
|
case 'ALL': return 'Total'
|
||||||
|
default: return 'Total'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
// Render Body based on metricType
|
// Render Body based on metricType
|
||||||
const renderCardBody = () => {
|
const renderCardBody = () => {
|
||||||
|
|
@ -420,21 +441,32 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
|
|
||||||
case 'TOP_TASKS': {
|
case 'TOP_TASKS': {
|
||||||
const top2 = [...filteredTasks]
|
const top2 = [...filteredTasks]
|
||||||
.sort((a, b) => calculateTaskTotalSeconds(b) - calculateTaskTotalSeconds(a))
|
.map((t) => ({
|
||||||
|
task: t,
|
||||||
|
sec: calculateTaskSecondsInRange(
|
||||||
|
t,
|
||||||
|
currentRange.startDate,
|
||||||
|
currentRange.endDate,
|
||||||
|
activeTask?.id,
|
||||||
|
activeSeconds
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.sec > 0)
|
||||||
|
.sort((a, b) => b.sec - a.sec)
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 my-auto">
|
<div className="space-y-1 my-auto">
|
||||||
{top2.length === 0 ? (
|
{top2.length === 0 ? (
|
||||||
<p className="text-[11px] text-muted italic">Nenhuma tarefa encontrada.</p>
|
<p className="text-[11px] text-muted italic">Nenhuma atividade no período.</p>
|
||||||
) : (
|
) : (
|
||||||
top2.map((t, i) => (
|
top2.map(({ task: t, sec }, i) => (
|
||||||
<div key={t.id} className="flex items-center justify-between text-[10px] gap-2">
|
<div key={t.id} className="flex items-center justify-between text-[10px] gap-2">
|
||||||
<span className="truncate text-foreground font-semibold">
|
<span className="truncate text-foreground font-semibold">
|
||||||
<strong className="text-muted mr-1">#{i + 1}</strong> {t.title}
|
<strong className="text-muted mr-1">#{i + 1}</strong> {t.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-muted shrink-0">
|
<span className="font-mono text-muted shrink-0">
|
||||||
{formatSecondsToTimer(calculateTaskTotalSeconds(t))}
|
{formatSecondsToTimer(sec)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -576,7 +608,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
|
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
|
||||||
{periodLabels[card.period]}
|
{periodBadgeLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{projectName && (
|
{projectName && (
|
||||||
|
|
|
||||||
|
|
@ -258,10 +258,17 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(val) => setPeriod(val as BiPeriodFilter)}
|
onChange={(val) => setPeriod(val as BiPeriodFilter)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'ALL', label: 'Todo o Histórico' },
|
{ value: 'INHERIT', label: '🔗 Seguir Período da Página (Recomendado)' },
|
||||||
{ value: 'TODAY', label: 'Apenas Hoje' },
|
{ value: 'TODAY', label: '📅 Apenas Hoje' },
|
||||||
|
{ value: 'YESTERDAY', label: 'Ontem' },
|
||||||
{ value: 'WEEK', label: 'Esta Semana' },
|
{ value: 'WEEK', label: 'Esta Semana' },
|
||||||
|
{ value: 'LAST_WEEK', label: 'Semana Passada' },
|
||||||
{ value: 'MONTH', label: 'Este Mês' },
|
{ 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>
|
</div>
|
||||||
|
|
|
||||||
315
frontend/src/components/dashboard/PeriodSelector.tsx
Normal file
315
frontend/src/components/dashboard/PeriodSelector.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
Clock,
|
||||||
|
Check,
|
||||||
|
CalendarRange,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
type PeriodPreset,
|
||||||
|
type DateRange,
|
||||||
|
PERIOD_PRESET_LABELS,
|
||||||
|
getDateRangeForPreset,
|
||||||
|
formatDatePtBr,
|
||||||
|
formatDateYmd,
|
||||||
|
} from '../../lib/periodUtils'
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
value: PeriodPreset
|
||||||
|
customStart?: string
|
||||||
|
customEnd?: string
|
||||||
|
onChange: (preset: PeriodPreset, customStart?: string, customEnd?: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_PRESETS: PeriodPreset[] = ['today', 'this_week', 'this_month', 'last_3_months', 'this_year']
|
||||||
|
|
||||||
|
const SECONDARY_PRESETS: PeriodPreset[] = [
|
||||||
|
'yesterday',
|
||||||
|
'last_week',
|
||||||
|
'last_month',
|
||||||
|
'last_6_months',
|
||||||
|
'all',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PeriodSelector: React.FC<PeriodSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
customStart,
|
||||||
|
customEnd,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
const [customModalOpen, setCustomModalOpen] = useState(false)
|
||||||
|
|
||||||
|
// Local state for custom dates
|
||||||
|
const defaultDates = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
return {
|
||||||
|
start: customStart || formatDateYmd(firstDay),
|
||||||
|
end: customEnd || formatDateYmd(lastDay),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tempStart, setTempStart] = useState(defaultDates().start)
|
||||||
|
const [tempEnd, setTempEnd] = useState(defaultDates().end)
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [dropdownOpen])
|
||||||
|
|
||||||
|
const currentRange: DateRange = getDateRangeForPreset(value, customStart, customEnd)
|
||||||
|
|
||||||
|
const handleSelectPreset = (preset: PeriodPreset) => {
|
||||||
|
if (preset === 'custom') {
|
||||||
|
setCustomModalOpen(true)
|
||||||
|
setDropdownOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDropdownOpen(false)
|
||||||
|
onChange(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyCustom = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!tempStart || !tempEnd) return
|
||||||
|
onChange('custom', tempStart, tempEnd)
|
||||||
|
setCustomModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecondaryActive = SECONDARY_PRESETS.includes(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-2.5 sm:p-3 rounded-2xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel ${className}`}>
|
||||||
|
{/* Left: Buttons & Presets */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 text-xs font-bold text-muted mr-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span className="hidden md:inline">Período:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner flex-wrap gap-1">
|
||||||
|
{PRIMARY_PRESETS.map((p) => {
|
||||||
|
const active = value === p
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPreset(p)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{PERIOD_PRESET_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* More Presets Dropdown */}
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
|
isSecondaryActive
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{isSecondaryActive ? PERIOD_PRESET_LABELS[value] : 'Outros'}</span>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute left-0 sm:right-0 sm:left-auto top-full mt-1.5 w-48 rounded-2xl bg-surface/95 backdrop-blur-xl border border-border-soft p-1.5 shadow-2xl z-50 animate-in fade-in zoom-in-95 duration-150 space-y-0.5">
|
||||||
|
{SECONDARY_PRESETS.map((p) => {
|
||||||
|
const active = value === p
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPreset(p)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-semibold text-left transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-brand/10 text-brand font-bold'
|
||||||
|
: 'text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{PERIOD_PRESET_LABELS[p]}</span>
|
||||||
|
{active && <Check className="w-3.5 h-3.5 text-brand" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTempStart(customStart || defaultDates().start)
|
||||||
|
setTempEnd(customEnd || defaultDates().end)
|
||||||
|
setCustomModalOpen(true)
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
|
value === 'custom'
|
||||||
|
? 'bg-accent text-accent-foreground shadow-md shadow-accent/25'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarRange className="w-3.5 h-3.5" />
|
||||||
|
<span>Personalizado</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Active Date Range Badge */}
|
||||||
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-surface-raised border border-border-soft text-xs font-mono font-semibold text-foreground/90 shadow-2xs">
|
||||||
|
<Clock className="w-3 h-3 text-brand shrink-0" />
|
||||||
|
<span>
|
||||||
|
{value === 'all'
|
||||||
|
? 'Todo o Histórico'
|
||||||
|
: value === 'today'
|
||||||
|
? `Hoje (${formatDatePtBr(currentRange.startDate)})`
|
||||||
|
: value === 'yesterday'
|
||||||
|
? `Ontem (${formatDatePtBr(currentRange.startDate)})`
|
||||||
|
: `${formatDatePtBr(currentRange.startDate)} até ${formatDatePtBr(currentRange.endDate)}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal / Dialog for Custom Date Range */}
|
||||||
|
{customModalOpen && (
|
||||||
|
<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-md bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-border-soft">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-2xl bg-accent/15 text-accent border border-accent/25 flex items-center justify-center">
|
||||||
|
<CalendarRange className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-extrabold text-foreground">
|
||||||
|
Definir Período Personalizado
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Selecione a data de início e término desejadas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomModalOpen(false)}
|
||||||
|
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleApplyCustom} className="p-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">Data Inicial</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempStart}
|
||||||
|
onChange={(e) => setTempStart(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">Data Final</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempEnd}
|
||||||
|
onChange={(e) => setTempEnd(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick shortcut pills inside modal */}
|
||||||
|
<div className="pt-2 border-t border-border-soft/60 space-y-1.5">
|
||||||
|
<span className="text-[10px] font-bold text-muted uppercase tracking-wider block">
|
||||||
|
Atalhos de Meses:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: 'Últimos 30 dias',
|
||||||
|
days: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Últimos 60 dias',
|
||||||
|
days: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Últimos 90 dias',
|
||||||
|
days: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Últimos 180 dias',
|
||||||
|
days: 180,
|
||||||
|
},
|
||||||
|
].map((shortcut) => (
|
||||||
|
<button
|
||||||
|
key={shortcut.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const now = new Date()
|
||||||
|
const s = new Date(now.getTime() - shortcut.days * 24 * 60 * 60 * 1000)
|
||||||
|
setTempStart(formatDateYmd(s))
|
||||||
|
setTempEnd(formatDateYmd(now))
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-surface-raised hover:bg-brand/10 hover:text-brand border border-border-soft transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{shortcut.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border-soft">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-strong text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Aplicar Período
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
frontend/src/lib/periodUtils.ts
Normal file
237
frontend/src/lib/periodUtils.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import type { Task } from '../types/task'
|
||||||
|
|
||||||
|
export type PeriodPreset =
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'this_week'
|
||||||
|
| 'last_week'
|
||||||
|
| 'this_month'
|
||||||
|
| 'last_month'
|
||||||
|
| 'last_3_months'
|
||||||
|
| 'last_6_months'
|
||||||
|
| 'this_year'
|
||||||
|
| 'custom'
|
||||||
|
| 'all'
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERIOD_PRESET_LABELS: Record<PeriodPreset, string> = {
|
||||||
|
today: 'Hoje',
|
||||||
|
yesterday: 'Ontem',
|
||||||
|
this_week: 'Esta Semana',
|
||||||
|
last_week: 'Semana Passada',
|
||||||
|
this_month: 'Este Mês',
|
||||||
|
last_month: 'Mês Passado',
|
||||||
|
last_3_months: 'Últimos 3 Meses',
|
||||||
|
last_6_months: 'Últimos 6 Meses',
|
||||||
|
this_year: 'Este Ano',
|
||||||
|
custom: 'Personalizado',
|
||||||
|
all: 'Todo o Histórico',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns startDate and endDate boundaries for a given preset.
|
||||||
|
*/
|
||||||
|
export function getDateRangeForPreset(
|
||||||
|
preset: PeriodPreset,
|
||||||
|
customStartStr?: string,
|
||||||
|
customEndStr?: string
|
||||||
|
): DateRange {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case 'today': {
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Hoje' }
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Ontem' }
|
||||||
|
}
|
||||||
|
case 'this_week': {
|
||||||
|
const start = new Date(now)
|
||||||
|
const day = start.getDay()
|
||||||
|
const diffToMonday = (day === 0 ? -6 : 1) - day
|
||||||
|
start.setDate(start.getDate() + diffToMonday)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(end.getDate() + 6)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Esta Semana' }
|
||||||
|
}
|
||||||
|
case 'last_week': {
|
||||||
|
const start = new Date(now)
|
||||||
|
const day = start.getDay()
|
||||||
|
const diffToMonday = (day === 0 ? -6 : 1) - day - 7
|
||||||
|
start.setDate(start.getDate() + diffToMonday)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(end.getDate() + 6)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Semana Passada' }
|
||||||
|
}
|
||||||
|
case 'this_month': {
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Este Mês' }
|
||||||
|
}
|
||||||
|
case 'last_month': {
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Mês Passado' }
|
||||||
|
}
|
||||||
|
case 'last_3_months': {
|
||||||
|
// 3 months including current month (e.g. from 1st of month - 2 up to today/end of month)
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Últimos 3 Meses' }
|
||||||
|
}
|
||||||
|
case 'last_6_months': {
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Últimos 6 Meses' }
|
||||||
|
}
|
||||||
|
case 'this_year': {
|
||||||
|
const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: `Ano ${now.getFullYear()}` }
|
||||||
|
}
|
||||||
|
case 'custom': {
|
||||||
|
if (customStartStr && customEndStr) {
|
||||||
|
const [sy, sm, sd] = customStartStr.split('-').map(Number)
|
||||||
|
const [ey, em, ed] = customEndStr.split('-').map(Number)
|
||||||
|
const start = new Date(sy, sm - 1, sd, 0, 0, 0, 0)
|
||||||
|
const end = new Date(ey, em - 1, ed, 23, 59, 59, 999)
|
||||||
|
return {
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
label: `${formatDatePtBr(start)} a ${formatDatePtBr(end)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to this month if missing custom values
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Personalizado' }
|
||||||
|
}
|
||||||
|
case 'all':
|
||||||
|
default: {
|
||||||
|
const start = new Date(2020, 0, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(2035, 11, 31, 23, 59, 59, 999)
|
||||||
|
return { startDate: start, endDate: end, label: 'Todo o Histórico' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDatePtBr(d: Date): string {
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = d.getFullYear()
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateYmd(d: Date): string {
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = d.getFullYear()
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the exact worked seconds for a given task overlapping the [rangeStart, rangeEnd] window.
|
||||||
|
* Also incorporates currently running timers if applicable.
|
||||||
|
*/
|
||||||
|
export function calculateTaskSecondsInRange(
|
||||||
|
task: Task,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
activeTaskId?: number,
|
||||||
|
activeSeconds?: number
|
||||||
|
): number {
|
||||||
|
if (!task.history || task.history.length === 0) {
|
||||||
|
if (activeTaskId === task.id && activeSeconds && activeSeconds > 0) {
|
||||||
|
return activeSeconds
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSec = 0
|
||||||
|
const rangeStartMs = rangeStart.getTime()
|
||||||
|
const rangeEndMs = rangeEnd.getTime()
|
||||||
|
const nowMs = Date.now()
|
||||||
|
|
||||||
|
for (const item of task.history) {
|
||||||
|
if (!item.startAt) continue
|
||||||
|
|
||||||
|
const utcDateStr = item.startAt.endsWith('Z') ? item.startAt : `${item.startAt}Z`
|
||||||
|
const itemStartMs = new Date(utcDateStr).getTime()
|
||||||
|
if (isNaN(itemStartMs)) continue
|
||||||
|
|
||||||
|
let itemEndMs: number
|
||||||
|
if (item.endAt) {
|
||||||
|
const utcEndStr = item.endAt.endsWith('Z') ? item.endAt : `${item.endAt}Z`
|
||||||
|
itemEndMs = new Date(utcEndStr).getTime()
|
||||||
|
} else {
|
||||||
|
// Currently active interval
|
||||||
|
itemEndMs = nowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(itemEndMs) || itemEndMs <= itemStartMs) continue
|
||||||
|
|
||||||
|
// Intersect [itemStartMs, itemEndMs] with [rangeStartMs, rangeEndMs]
|
||||||
|
const overlapStart = Math.max(itemStartMs, rangeStartMs)
|
||||||
|
const overlapEnd = Math.min(itemEndMs, rangeEndMs)
|
||||||
|
|
||||||
|
if (overlapEnd > overlapStart) {
|
||||||
|
totalSec += Math.floor((overlapEnd - overlapStart) / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSec
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a task was finished within [rangeStart, rangeEnd].
|
||||||
|
*/
|
||||||
|
export function isTaskFinishedInRange(task: Task, rangeStart: Date, rangeEnd: Date): boolean {
|
||||||
|
if (task.status !== 'COMPLETED') return false
|
||||||
|
|
||||||
|
if (task.finishedAt) {
|
||||||
|
const utcFinStr = task.finishedAt.endsWith('Z') ? task.finishedAt : `${task.finishedAt}Z`
|
||||||
|
const finDate = new Date(utcFinStr)
|
||||||
|
if (!isNaN(finDate.getTime())) {
|
||||||
|
return finDate >= rangeStart && finDate <= rangeEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Check if the last history item was within the range
|
||||||
|
if (task.history && task.history.length > 0) {
|
||||||
|
const last = task.history[task.history.length - 1]
|
||||||
|
if (last.endAt) {
|
||||||
|
const utcEndStr = last.endAt.endsWith('Z') ? last.endAt : `${last.endAt}Z`
|
||||||
|
const endDate = new Date(utcEndStr)
|
||||||
|
if (!isNaN(endDate.getTime())) {
|
||||||
|
return endDate >= rangeStart && endDate <= rangeEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a task had any active work, history, or creation in [rangeStart, rangeEnd].
|
||||||
|
*/
|
||||||
|
export function hasTaskActivityInRange(task: Task, rangeStart: Date, rangeEnd: Date): boolean {
|
||||||
|
const seconds = calculateTaskSecondsInRange(task, rangeStart, rangeEnd)
|
||||||
|
if (seconds > 0) return true
|
||||||
|
if (isTaskFinishedInRange(task, rangeStart, rangeEnd)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,18 @@ export type BiMetricType =
|
||||||
| 'ACTIVE_TIMER'
|
| 'ACTIVE_TIMER'
|
||||||
| 'TOP_TASKS'
|
| 'TOP_TASKS'
|
||||||
|
|
||||||
export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH'
|
export type BiPeriodFilter =
|
||||||
|
| 'INHERIT'
|
||||||
|
| 'ALL'
|
||||||
|
| 'TODAY'
|
||||||
|
| 'YESTERDAY'
|
||||||
|
| 'WEEK'
|
||||||
|
| 'LAST_WEEK'
|
||||||
|
| 'MONTH'
|
||||||
|
| 'LAST_MONTH'
|
||||||
|
| 'LAST_3_MONTHS'
|
||||||
|
| 'LAST_6_MONTHS'
|
||||||
|
| 'YEAR'
|
||||||
|
|
||||||
export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM'
|
export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ import type { Task, Project } from '../types/task'
|
||||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||||
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||||
|
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||||
|
import {
|
||||||
|
type PeriodPreset,
|
||||||
|
getDateRangeForPreset,
|
||||||
|
calculateTaskSecondsInRange,
|
||||||
|
isTaskFinishedInRange,
|
||||||
|
hasTaskActivityInRange,
|
||||||
|
} from '../lib/periodUtils'
|
||||||
import {
|
import {
|
||||||
getUserWorkGoal,
|
getUserWorkGoal,
|
||||||
calculateUserPeriodWorkedSeconds,
|
calculateUserPeriodWorkedSeconds,
|
||||||
|
|
@ -110,6 +118,32 @@ export const DashboardPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Period Preset State (Hoje, Esta Semana, Este Mês, Trimestre, Ano, etc.)
|
||||||
|
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>(() => {
|
||||||
|
return (localStorage.getItem('beetask_dashboard_period') as PeriodPreset) || 'this_month'
|
||||||
|
})
|
||||||
|
const [customStart, setCustomStart] = useState<string | undefined>(() => {
|
||||||
|
return localStorage.getItem('beetask_dashboard_custom_start') || undefined
|
||||||
|
})
|
||||||
|
const [customEnd, setCustomEnd] = useState<string | undefined>(() => {
|
||||||
|
return localStorage.getItem('beetask_dashboard_custom_end') || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePeriodChange = (preset: PeriodPreset, start?: string, end?: string) => {
|
||||||
|
setPeriodPreset(preset)
|
||||||
|
setCustomStart(start)
|
||||||
|
setCustomEnd(end)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('beetask_dashboard_period', preset)
|
||||||
|
if (start) localStorage.setItem('beetask_dashboard_custom_start', start)
|
||||||
|
if (end) localStorage.setItem('beetask_dashboard_custom_end', end)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDateRange = useMemo(() => {
|
||||||
|
return getDateRangeForPreset(periodPreset, customStart, customEnd)
|
||||||
|
}, [periodPreset, customStart, customEnd])
|
||||||
|
|
||||||
const activeOrgId = activeOrg?.id
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
|
|
@ -161,15 +195,50 @@ export const DashboardPage: React.FC = () => {
|
||||||
// Scope tasks based on viewScope
|
// Scope tasks based on viewScope
|
||||||
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
|
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
|
||||||
|
|
||||||
// KPIs dynamically calculated based on active scope
|
// KPIs dynamically calculated based on active scope AND active period
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
const isAll = periodPreset === 'all'
|
||||||
const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length
|
const start = activeDateRange.startDate
|
||||||
const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
|
const end = activeDateRange.endDate
|
||||||
const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
|
|
||||||
|
|
||||||
const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean))
|
let inProgress = 0
|
||||||
const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length
|
let completed = 0
|
||||||
|
let notStarted = 0
|
||||||
|
let totalSeconds = 0
|
||||||
|
const activeProjectIds = new Set<number>()
|
||||||
|
|
||||||
|
for (const t of scopeTasks) {
|
||||||
|
// Calculate period-accurate seconds
|
||||||
|
const sec = isAll
|
||||||
|
? calculateTaskTotalSeconds(t)
|
||||||
|
: calculateTaskSecondsInRange(t, start, end, currentActiveTask?.id, activeSeconds)
|
||||||
|
totalSeconds += sec
|
||||||
|
|
||||||
|
const hadActivity = isAll || sec > 0 || hasTaskActivityInRange(t, start, end)
|
||||||
|
|
||||||
|
// Completed in period
|
||||||
|
if (isAll) {
|
||||||
|
if (t.status === 'COMPLETED') completed++
|
||||||
|
else if (t.status === 'IN_PROGRESS') inProgress++
|
||||||
|
else if (t.status === 'NOT_STARTED') notStarted++
|
||||||
|
} else {
|
||||||
|
if (isTaskFinishedInRange(t, start, end)) {
|
||||||
|
completed++
|
||||||
|
} else if (t.status === 'IN_PROGRESS' || sec > 0) {
|
||||||
|
inProgress++
|
||||||
|
} else if (t.status === 'NOT_STARTED') {
|
||||||
|
notStarted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadActivity && t.project?.id) {
|
||||||
|
activeProjectIds.add(t.project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsCount = isAll
|
||||||
|
? (viewScope === 'mine' ? new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean)).size : projects.length)
|
||||||
|
: activeProjectIds.size
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inProgress,
|
inProgress,
|
||||||
|
|
@ -177,8 +246,17 @@ export const DashboardPage: React.FC = () => {
|
||||||
notStarted,
|
notStarted,
|
||||||
totalSeconds,
|
totalSeconds,
|
||||||
projectsCount,
|
projectsCount,
|
||||||
|
periodLabel: activeDateRange.label,
|
||||||
}
|
}
|
||||||
}, [scopeTasks, projects, viewScope])
|
}, [
|
||||||
|
scopeTasks,
|
||||||
|
projects,
|
||||||
|
viewScope,
|
||||||
|
periodPreset,
|
||||||
|
activeDateRange,
|
||||||
|
currentActiveTask?.id,
|
||||||
|
activeSeconds,
|
||||||
|
])
|
||||||
|
|
||||||
// Work Goal Progress in Current Period
|
// Work Goal Progress in Current Period
|
||||||
const goalProgress = useMemo(() => {
|
const goalProgress = useMemo(() => {
|
||||||
|
|
@ -216,10 +294,19 @@ export const DashboardPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [tasks, workGoal, currentActiveTask?.id, activeSeconds])
|
}, [tasks, workGoal, currentActiveTask?.id, activeSeconds])
|
||||||
|
|
||||||
// Recent 5 tasks based on active scope
|
// Recent tasks based on active scope & period activity
|
||||||
const recentTasks = useMemo(() => {
|
const recentTasks = useMemo(() => {
|
||||||
|
if (periodPreset === 'all') {
|
||||||
return [...scopeTasks].slice(0, 5)
|
return [...scopeTasks].slice(0, 5)
|
||||||
}, [scopeTasks])
|
}
|
||||||
|
const withActivity = scopeTasks.filter((t) =>
|
||||||
|
hasTaskActivityInRange(t, activeDateRange.startDate, activeDateRange.endDate)
|
||||||
|
)
|
||||||
|
if (withActivity.length > 0) {
|
||||||
|
return withActivity.slice(0, 5)
|
||||||
|
}
|
||||||
|
return [...scopeTasks].slice(0, 5)
|
||||||
|
}, [scopeTasks, periodPreset, activeDateRange])
|
||||||
|
|
||||||
// Toggle timer on a task
|
// Toggle timer on a task
|
||||||
const handleToggleTimer = async (task: Task) => {
|
const handleToggleTimer = async (task: Task) => {
|
||||||
|
|
@ -425,6 +512,14 @@ export const DashboardPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Global Period Selector */}
|
||||||
|
<PeriodSelector
|
||||||
|
value={periodPreset}
|
||||||
|
customStart={customStart}
|
||||||
|
customEnd={customEnd}
|
||||||
|
onChange={handlePeriodChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
|
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
|
@ -466,9 +561,11 @@ export const DashboardPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{viewScope === 'mine'
|
{periodPreset === 'all'
|
||||||
|
? (viewScope === 'mine'
|
||||||
? `${stats.notStarted} na sua fila aguardando`
|
? `${stats.notStarted} na sua fila aguardando`
|
||||||
: `${stats.notStarted} tarefas aguardando no backlog`}
|
: `${stats.notStarted} tarefas aguardando no backlog`)
|
||||||
|
: `Ativas no período (${stats.periodLabel})`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -500,9 +597,11 @@ export const DashboardPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{viewScope === 'mine'
|
{periodPreset === 'all'
|
||||||
|
? (viewScope === 'mine'
|
||||||
? `de ${myTasks.length} demandas suas`
|
? `de ${myTasks.length} demandas suas`
|
||||||
: `de ${tasks.length} demandas cadastradas`}
|
: `de ${tasks.length} demandas cadastradas`)
|
||||||
|
: `Entregas no período (${stats.periodLabel})`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -534,9 +633,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{viewScope === 'mine'
|
{formatSecondsToHuman(stats.totalSeconds)} em {stats.periodLabel}
|
||||||
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
|
|
||||||
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -568,9 +665,11 @@ export const DashboardPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{viewScope === 'mine'
|
{periodPreset === 'all'
|
||||||
|
? (viewScope === 'mine'
|
||||||
? 'Iniciativas com tarefas suas'
|
? 'Iniciativas com tarefas suas'
|
||||||
: 'Iniciativas ativas no workspace'}
|
: 'Iniciativas ativas no workspace')
|
||||||
|
: `Com atuação no período (${stats.periodLabel})`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -808,9 +907,12 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* Recent Sprint Tasks (Right 7 cols) */}
|
{/* Recent Sprint Tasks (Right 7 cols) */}
|
||||||
<div className="lg:col-span-7 space-y-4">
|
<div className="lg:col-span-7 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 flex-wrap">
|
||||||
<Zap className="w-4 h-4 text-accent" />
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
{viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'}
|
<span>{viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'}</span>
|
||||||
|
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-brand-soft text-brand border border-brand/20">
|
||||||
|
{stats.periodLabel}
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<Link
|
<Link
|
||||||
to="/tasks"
|
to="/tasks"
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ import {
|
||||||
type DashboardV2Card,
|
type DashboardV2Card,
|
||||||
} from '../lib/userPreferences'
|
} from '../lib/userPreferences'
|
||||||
import { getUserWorkGoal, type WorkGoal } from '../lib/userGoals'
|
import { getUserWorkGoal, type WorkGoal } from '../lib/userGoals'
|
||||||
|
import { type PeriodPreset } from '../lib/periodUtils'
|
||||||
import { BiUniformCard } from '../components/dashboard/BiUniformCard'
|
import { BiUniformCard } from '../components/dashboard/BiUniformCard'
|
||||||
import { CreateBiCardModal } from '../components/dashboard/CreateBiCardModal'
|
import { CreateBiCardModal } from '../components/dashboard/CreateBiCardModal'
|
||||||
|
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||||
|
|
||||||
export const DashboardV2Page: React.FC = () => {
|
export const DashboardV2Page: React.FC = () => {
|
||||||
const { user, activeOrg } = useAuth()
|
const { user, activeOrg } = useAuth()
|
||||||
|
|
@ -61,6 +63,28 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
'team'
|
'team'
|
||||||
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
|
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
|
||||||
|
|
||||||
|
// Global Period State
|
||||||
|
const [globalPeriod, setGlobalPeriod] = useState<PeriodPreset>(() => {
|
||||||
|
return (localStorage.getItem('beetask_bi_period') as PeriodPreset) || 'this_month'
|
||||||
|
})
|
||||||
|
const [globalCustomStart, setGlobalCustomStart] = useState<string | undefined>(() => {
|
||||||
|
return localStorage.getItem('beetask_bi_custom_start') || undefined
|
||||||
|
})
|
||||||
|
const [globalCustomEnd, setGlobalCustomEnd] = useState<string | undefined>(() => {
|
||||||
|
return localStorage.getItem('beetask_bi_custom_end') || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleGlobalPeriodChange = (preset: PeriodPreset, start?: string, end?: string) => {
|
||||||
|
setGlobalPeriod(preset)
|
||||||
|
setGlobalCustomStart(start)
|
||||||
|
setGlobalCustomEnd(end)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('beetask_bi_period', preset)
|
||||||
|
if (start) localStorage.setItem('beetask_bi_custom_start', start)
|
||||||
|
if (end) localStorage.setItem('beetask_bi_custom_end', end)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
const handleScopeChange = (newScope: 'mine' | 'team') => {
|
const handleScopeChange = (newScope: 'mine' | 'team') => {
|
||||||
setViewScope(newScope)
|
setViewScope(newScope)
|
||||||
try {
|
try {
|
||||||
|
|
@ -410,6 +434,14 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Global Period Selector */}
|
||||||
|
<PeriodSelector
|
||||||
|
value={globalPeriod}
|
||||||
|
customStart={globalCustomStart}
|
||||||
|
customEnd={globalCustomEnd}
|
||||||
|
onChange={handleGlobalPeriodChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Uniform Modular Grid */}
|
{/* Uniform Modular Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
|
@ -463,6 +495,9 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
activeSeconds={activeSeconds}
|
activeSeconds={activeSeconds}
|
||||||
workGoal={workGoal}
|
workGoal={workGoal}
|
||||||
globalScope={viewScope}
|
globalScope={viewScope}
|
||||||
|
globalPeriod={globalPeriod}
|
||||||
|
globalCustomStart={globalCustomStart}
|
||||||
|
globalCustomEnd={globalCustomEnd}
|
||||||
isBeingDragged={draggedCardId === card.id}
|
isBeingDragged={draggedCardId === card.id}
|
||||||
isDragOver={dragOverCardId === card.id}
|
isDragOver={dragOverCardId === card.id}
|
||||||
onToggleTimer={handleToggleTimer}
|
onToggleTimer={handleToggleTimer}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue