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 { Task, Project, Category } from '../../types/task'
|
||||
import {
|
||||
calculateTaskTotalSeconds,
|
||||
formatSecondsToHuman,
|
||||
formatSecondsToTimer,
|
||||
} from '../../lib/timeUtils'
|
||||
import {
|
||||
calculateUserPeriodWorkedSeconds,
|
||||
isSameDay,
|
||||
isSameWeek,
|
||||
isSameMonth,
|
||||
type WorkGoal,
|
||||
} from '../../lib/userGoals'
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getDateRangeForPreset,
|
||||
calculateTaskSecondsInRange,
|
||||
} from '../../lib/periodUtils'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
||||
interface BiUniformCardProps {
|
||||
|
|
@ -46,6 +47,9 @@ interface BiUniformCardProps {
|
|||
activeSeconds: number
|
||||
workGoal: WorkGoal
|
||||
globalScope?: 'mine' | 'team'
|
||||
globalPeriod?: PeriodPreset
|
||||
globalCustomStart?: string
|
||||
globalCustomEnd?: string
|
||||
isBeingDragged?: boolean
|
||||
isDragOver?: boolean
|
||||
onToggleTimer: (task: Task) => void
|
||||
|
|
@ -124,6 +128,9 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
activeSeconds,
|
||||
workGoal,
|
||||
globalScope = 'team',
|
||||
globalPeriod = 'this_month',
|
||||
globalCustomStart,
|
||||
globalCustomEnd,
|
||||
isBeingDragged = false,
|
||||
isDragOver = false,
|
||||
onToggleTimer,
|
||||
|
|
@ -138,7 +145,6 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
}) => {
|
||||
const { user } = useAuth()
|
||||
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
|
||||
const effectiveScope: 'mine' | 'team' =
|
||||
|
|
@ -148,6 +154,28 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
: 'team'
|
||||
: 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
|
||||
const filteredTasks = tasks.filter((t) => {
|
||||
// Scope filter (if 'mine', only user's tasks)
|
||||
|
|
@ -168,46 +196,39 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
return true
|
||||
})
|
||||
|
||||
// Period-aware seconds calculation
|
||||
// Period-aware seconds calculation using exact range intersection
|
||||
const periodSeconds = filteredTasks.reduce((acc, t) => {
|
||||
let taskSec = 0
|
||||
if (card.period === 'ALL') {
|
||||
taskSec = calculateTaskTotalSeconds(t)
|
||||
} else {
|
||||
for (const item of t.history || []) {
|
||||
if (!item.startAt) continue
|
||||
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
|
||||
return acc + calculateTaskSecondsInRange(
|
||||
t,
|
||||
currentRange.startDate,
|
||||
currentRange.endDate,
|
||||
activeTask?.id,
|
||||
activeSeconds
|
||||
)
|
||||
}, 0)
|
||||
|
||||
// Associated names
|
||||
const projectName = projects.find((p) => p.id === card.projectId)?.name
|
||||
const categoryName = categories.find((c) => c.id === card.categoryId)?.name
|
||||
|
||||
const periodLabels: Record<string, string> = {
|
||||
ALL: 'Total',
|
||||
TODAY: 'Hoje',
|
||||
WEEK: 'Esta Semana',
|
||||
MONTH: 'Este Mês',
|
||||
}
|
||||
const periodBadgeLabel = (() => {
|
||||
if (!card.period || card.period === 'INHERIT') {
|
||||
return currentRange.label
|
||||
}
|
||||
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
|
||||
const renderCardBody = () => {
|
||||
|
|
@ -420,21 +441,32 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
|
||||
case 'TOP_TASKS': {
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="space-y-1 my-auto">
|
||||
{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">
|
||||
<span className="truncate text-foreground font-semibold">
|
||||
<strong className="text-muted mr-1">#{i + 1}</strong> {t.title}
|
||||
</span>
|
||||
<span className="font-mono text-muted shrink-0">
|
||||
{formatSecondsToTimer(calculateTaskTotalSeconds(t))}
|
||||
{formatSecondsToTimer(sec)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -576,7 +608,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
|||
</span>
|
||||
|
||||
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
|
||||
{periodLabels[card.period]}
|
||||
{periodBadgeLabel}
|
||||
</span>
|
||||
|
||||
{projectName && (
|
||||
|
|
|
|||
|
|
@ -258,10 +258,17 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
|||
value={period}
|
||||
onChange={(val) => setPeriod(val as BiPeriodFilter)}
|
||||
options={[
|
||||
{ value: 'ALL', label: 'Todo o Histórico' },
|
||||
{ value: 'TODAY', label: 'Apenas Hoje' },
|
||||
{ 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>
|
||||
|
|
|
|||
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'
|
||||
| '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'
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import type { Task, Project } from '../types/task'
|
|||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getDateRangeForPreset,
|
||||
calculateTaskSecondsInRange,
|
||||
isTaskFinishedInRange,
|
||||
hasTaskActivityInRange,
|
||||
} from '../lib/periodUtils'
|
||||
import {
|
||||
getUserWorkGoal,
|
||||
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 loadData = useCallback(async () => {
|
||||
|
|
@ -161,15 +195,50 @@ export const DashboardPage: React.FC = () => {
|
|||
// Scope tasks based on viewScope
|
||||
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 inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||
const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length
|
||||
const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||
const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
|
||||
const isAll = periodPreset === 'all'
|
||||
const start = activeDateRange.startDate
|
||||
const end = activeDateRange.endDate
|
||||
|
||||
const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean))
|
||||
const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length
|
||||
let inProgress = 0
|
||||
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 {
|
||||
inProgress,
|
||||
|
|
@ -177,8 +246,17 @@ export const DashboardPage: React.FC = () => {
|
|||
notStarted,
|
||||
totalSeconds,
|
||||
projectsCount,
|
||||
periodLabel: activeDateRange.label,
|
||||
}
|
||||
}, [scopeTasks, projects, viewScope])
|
||||
}, [
|
||||
scopeTasks,
|
||||
projects,
|
||||
viewScope,
|
||||
periodPreset,
|
||||
activeDateRange,
|
||||
currentActiveTask?.id,
|
||||
activeSeconds,
|
||||
])
|
||||
|
||||
// Work Goal Progress in Current Period
|
||||
const goalProgress = useMemo(() => {
|
||||
|
|
@ -216,10 +294,19 @@ export const DashboardPage: React.FC = () => {
|
|||
}
|
||||
}, [tasks, workGoal, currentActiveTask?.id, activeSeconds])
|
||||
|
||||
// Recent 5 tasks based on active scope
|
||||
// Recent tasks based on active scope & period activity
|
||||
const recentTasks = useMemo(() => {
|
||||
if (periodPreset === 'all') {
|
||||
return [...scopeTasks].slice(0, 5)
|
||||
}
|
||||
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])
|
||||
}, [scopeTasks, periodPreset, activeDateRange])
|
||||
|
||||
// Toggle timer on a task
|
||||
const handleToggleTimer = async (task: Task) => {
|
||||
|
|
@ -425,6 +512,14 @@ export const DashboardPage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Period Selector */}
|
||||
<PeriodSelector
|
||||
value={periodPreset}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
onChange={handlePeriodChange}
|
||||
/>
|
||||
|
||||
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
|
||||
{loading ? (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted font-medium mt-1">
|
||||
{viewScope === 'mine'
|
||||
? `${stats.notStarted} na sua fila aguardando`
|
||||
: `${stats.notStarted} tarefas aguardando no backlog`}
|
||||
{periodPreset === 'all'
|
||||
? (viewScope === 'mine'
|
||||
? `${stats.notStarted} na sua fila aguardando`
|
||||
: `${stats.notStarted} tarefas aguardando no backlog`)
|
||||
: `Ativas no período (${stats.periodLabel})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -500,9 +597,11 @@ export const DashboardPage: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted font-medium mt-1">
|
||||
{viewScope === 'mine'
|
||||
? `de ${myTasks.length} demandas suas`
|
||||
: `de ${tasks.length} demandas cadastradas`}
|
||||
{periodPreset === 'all'
|
||||
? (viewScope === 'mine'
|
||||
? `de ${myTasks.length} demandas suas`
|
||||
: `de ${tasks.length} demandas cadastradas`)
|
||||
: `Entregas no período (${stats.periodLabel})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -534,9 +633,7 @@ export const DashboardPage: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted font-medium mt-1">
|
||||
{viewScope === 'mine'
|
||||
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
|
||||
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
|
||||
{formatSecondsToHuman(stats.totalSeconds)} em {stats.periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -568,9 +665,11 @@ export const DashboardPage: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted font-medium mt-1">
|
||||
{viewScope === 'mine'
|
||||
? 'Iniciativas com tarefas suas'
|
||||
: 'Iniciativas ativas no workspace'}
|
||||
{periodPreset === 'all'
|
||||
? (viewScope === 'mine'
|
||||
? 'Iniciativas com tarefas suas'
|
||||
: 'Iniciativas ativas no workspace')
|
||||
: `Com atuação no período (${stats.periodLabel})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -808,9 +907,12 @@ export const DashboardPage: React.FC = () => {
|
|||
{/* Recent Sprint Tasks (Right 7 cols) */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
<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" />
|
||||
{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>
|
||||
<Link
|
||||
to="/tasks"
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ import {
|
|||
type DashboardV2Card,
|
||||
} from '../lib/userPreferences'
|
||||
import { getUserWorkGoal, type WorkGoal } from '../lib/userGoals'
|
||||
import { type PeriodPreset } from '../lib/periodUtils'
|
||||
import { BiUniformCard } from '../components/dashboard/BiUniformCard'
|
||||
import { CreateBiCardModal } from '../components/dashboard/CreateBiCardModal'
|
||||
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
|
||||
|
||||
export const DashboardV2Page: React.FC = () => {
|
||||
const { user, activeOrg } = useAuth()
|
||||
|
|
@ -61,6 +63,28 @@ export const DashboardV2Page: React.FC = () => {
|
|||
'team'
|
||||
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') => {
|
||||
setViewScope(newScope)
|
||||
try {
|
||||
|
|
@ -410,6 +434,14 @@ export const DashboardV2Page: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Period Selector */}
|
||||
<PeriodSelector
|
||||
value={globalPeriod}
|
||||
customStart={globalCustomStart}
|
||||
customEnd={globalCustomEnd}
|
||||
onChange={handleGlobalPeriodChange}
|
||||
/>
|
||||
|
||||
{/* Uniform Modular Grid */}
|
||||
{loading ? (
|
||||
<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}
|
||||
workGoal={workGoal}
|
||||
globalScope={viewScope}
|
||||
globalPeriod={globalPeriod}
|
||||
globalCustomStart={globalCustomStart}
|
||||
globalCustomEnd={globalCustomEnd}
|
||||
isBeingDragged={draggedCardId === card.id}
|
||||
isDragOver={dragOverCardId === card.id}
|
||||
onToggleTimer={handleToggleTimer}
|
||||
|
|
|
|||
Loading…
Reference in a new issue