feat: replace unstyled native html selects with rich custom styled select components
This commit is contained in:
parent
6412bb20d1
commit
f2f9666fcf
4 changed files with 270 additions and 105 deletions
|
|
@ -5,6 +5,7 @@ import type { OrganizationMember } from '../../services/organizationService'
|
|||
import { projectService } from '../../services/projectService'
|
||||
import { categoryService } from '../../services/categoryService'
|
||||
import { organizationService } from '../../services/organizationService'
|
||||
import { CustomSelect } from '../ui/Select'
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -272,18 +273,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
>
|
||||
<option value="">Sem projeto</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CustomSelect
|
||||
value={projectId ?? 0}
|
||||
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
|
||||
placeholder="Sem projeto"
|
||||
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
|
||||
options={[
|
||||
{ value: 0, label: 'Sem projeto' },
|
||||
...projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
color: p.color || '#0f766e',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -297,7 +300,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddCategory(!showAddCategory)}
|
||||
className="text-[11px] text-accent hover:underline font-medium"
|
||||
className="text-[11px] text-accent hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{showAddCategory ? 'Cancelar' : '+ Nova'}
|
||||
</button>
|
||||
|
|
@ -321,18 +324,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={categoryId ?? ''}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
>
|
||||
<option value="">Sem categoria</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CustomSelect
|
||||
value={categoryId ?? 0}
|
||||
onChange={(val) => setCategoryId(Number(val) === 0 ? undefined : Number(val))}
|
||||
placeholder="Sem categoria"
|
||||
leftIcon={<Tag className="w-3.5 h-3.5 text-accent" />}
|
||||
options={[
|
||||
{ value: 0, label: 'Sem categoria' },
|
||||
...categories.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
color: c.color || '#d97706',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -345,20 +350,20 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
<User className="w-3.5 h-3.5 text-muted" />
|
||||
Responsável
|
||||
</label>
|
||||
<select
|
||||
value={assigneeUserId ?? ''}
|
||||
onChange={(e) =>
|
||||
setAssigneeUserId(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
>
|
||||
<option value="">Atribuir a mim mesmo / Sem responsável</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.userId} value={m.userId}>
|
||||
{m.userName} ({m.userEmail})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CustomSelect
|
||||
value={assigneeUserId ?? 0}
|
||||
onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))}
|
||||
placeholder="Atribuir a mim mesmo / Sem responsável"
|
||||
leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
|
||||
options={[
|
||||
{ value: 0, label: 'Sem responsável (atribuir a mim)' },
|
||||
...members.map((m) => ({
|
||||
value: m.userId,
|
||||
label: m.userName,
|
||||
description: m.userEmail,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
|
|
@ -366,16 +371,16 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||
Status Inicial
|
||||
</label>
|
||||
<select
|
||||
<CustomSelect
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as TaskStatus)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
>
|
||||
<option value="NOT_STARTED">A Fazer (NOT_STARTED)</option>
|
||||
<option value="IN_PROGRESS">Em Andamento (IN_PROGRESS)</option>
|
||||
<option value="COMPLETED">Concluído (COMPLETED)</option>
|
||||
<option value="CANCELED">Cancelado (CANCELED)</option>
|
||||
</select>
|
||||
onChange={(val) => setStatus(val as TaskStatus)}
|
||||
options={[
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react'
|
||||
import type { Task, TaskStatus } from '../../types/task'
|
||||
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
||||
import { CustomSelect } from '../ui/Select'
|
||||
|
||||
interface TaskListViewProps {
|
||||
tasks: Task[]
|
||||
|
|
@ -12,24 +13,6 @@ interface TaskListViewProps {
|
|||
activeTaskSeconds?: number
|
||||
}
|
||||
|
||||
const statusBadge: Record<TaskStatus, { label: string; badgeClass: string }> = {
|
||||
NOT_STARTED: {
|
||||
label: 'A Fazer',
|
||||
badgeClass: 'bg-zinc-800 text-zinc-300 border-zinc-700/60',
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
label: 'Em Andamento',
|
||||
badgeClass: 'bg-teal-500/15 text-teal-300 border-teal-500/30',
|
||||
},
|
||||
COMPLETED: {
|
||||
label: 'Concluído',
|
||||
badgeClass: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||
},
|
||||
CANCELED: {
|
||||
label: 'Cancelado',
|
||||
badgeClass: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
|
||||
},
|
||||
}
|
||||
|
||||
export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||
tasks,
|
||||
|
|
@ -145,17 +128,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
|
||||
{/* Status Dropdown */}
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<select
|
||||
<div className="w-36">
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={task.status}
|
||||
onChange={(e) => onStatusChange(task, e.target.value as TaskStatus)}
|
||||
aria-label="Status da tarefa"
|
||||
className={`text-[11px] font-medium px-2 py-1 rounded-lg border cursor-pointer focus:outline-none focus:ring-1 focus:ring-brand ${statusBadge[task.status].badgeClass}`}
|
||||
>
|
||||
<option value="NOT_STARTED" className="bg-surface-raised text-foreground">A Fazer</option>
|
||||
<option value="IN_PROGRESS" className="bg-surface-raised text-foreground">Em Andamento</option>
|
||||
<option value="COMPLETED" className="bg-surface-raised text-foreground">Concluído</option>
|
||||
<option value="CANCELED" className="bg-surface-raised text-foreground">Cancelado</option>
|
||||
</select>
|
||||
onChange={(newStatus) => onStatusChange(task, newStatus as TaskStatus)}
|
||||
options={[
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Time Spent */}
|
||||
|
|
|
|||
176
new-frontend/src/components/ui/Select.tsx
Normal file
176
new-frontend/src/components/ui/Select.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
export interface SelectOption<T = string | number> {
|
||||
value: T
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
color?: string
|
||||
badgeClass?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CustomSelectProps<T = string | number> {
|
||||
value: T | undefined
|
||||
onChange: (value: T) => void
|
||||
options: SelectOption<T>[]
|
||||
placeholder?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
disabled?: boolean
|
||||
leftIcon?: ReactNode
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function CustomSelect<T extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Selecione uma opção',
|
||||
className = '',
|
||||
triggerClassName = '',
|
||||
disabled = false,
|
||||
leftIcon,
|
||||
size = 'md',
|
||||
}: CustomSelectProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Find currently selected option
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (optValue: T) => {
|
||||
onChange(optValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isSmall = size === 'sm'
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative inline-block w-full ${className}`}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between gap-2.5 rounded-xl border border-border-soft bg-surface-raised/90 backdrop-blur-md text-foreground transition-all duration-200 outline-none select-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer ${
|
||||
isOpen
|
||||
? 'border-brand ring-2 ring-brand/20 shadow-xs'
|
||||
: 'hover:border-brand/40 hover:bg-surface-raised'
|
||||
} ${isSmall ? 'px-2.5 py-1 text-xs' : 'px-3.5 py-2.5 text-xs'} ${triggerClassName}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
{/* Custom Left Icon if supplied */}
|
||||
{leftIcon && <span className="text-muted shrink-0">{leftIcon}</span>}
|
||||
|
||||
{/* Option's custom icon or colored dot */}
|
||||
{selectedOption?.color && (
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: selectedOption.color }}
|
||||
/>
|
||||
)}
|
||||
{selectedOption?.icon && (
|
||||
<span className="shrink-0">{selectedOption.icon}</span>
|
||||
)}
|
||||
|
||||
{/* Label or Placeholder */}
|
||||
{selectedOption ? (
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate text-muted">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron Indicator */}
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-muted transition-transform duration-200 shrink-0 ${
|
||||
isOpen ? 'rotate-180 text-brand' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Popover */}
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 right-0 mt-1.5 z-50 min-w-[200px] max-h-64 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 backdrop-blur-xl p-1.5 shadow-panel animate-in fade-in zoom-in-95 duration-150">
|
||||
<div className="space-y-0.5">
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`w-full flex items-center justify-between gap-2.5 px-3 py-2 rounded-xl text-left text-xs transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-brand/12 text-brand font-semibold'
|
||||
: 'text-foreground hover:bg-surface-raised hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 truncate">
|
||||
{/* Colored dot */}
|
||||
{option.color && (
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0 shadow-xs"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{option.icon && (
|
||||
<span className="shrink-0">{option.icon}</span>
|
||||
)}
|
||||
|
||||
{/* Label and optional description */}
|
||||
<div className="truncate">
|
||||
<p className="truncate leading-tight">{option.label}</p>
|
||||
{option.description && (
|
||||
<p className="text-[10px] text-muted truncate mt-0.5 font-normal">
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkmark indicator */}
|
||||
{isSelected && (
|
||||
<Check className="w-3.5 h-3.5 text-brand shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import type { Task, TaskStatus, Project } from '../types/task'
|
|||
import { TaskKanbanView } from '../components/tasks/TaskKanbanView'
|
||||
import { TaskListView } from '../components/tasks/TaskListView'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { CustomSelect } from '../components/ui/Select'
|
||||
|
||||
export const TasksPage: React.FC = () => {
|
||||
const { activeOrg } = useAuth()
|
||||
|
|
@ -367,39 +368,37 @@ export const TasksPage: React.FC = () => {
|
|||
|
||||
{/* Project Filter */}
|
||||
{projects.length > 0 && (
|
||||
<div className="relative">
|
||||
<select
|
||||
<div className="w-48">
|
||||
<CustomSelect
|
||||
value={selectedProjectId}
|
||||
onChange={(e) =>
|
||||
setSelectedProjectId(
|
||||
e.target.value === 'ALL' ? 'ALL' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="pl-7 pr-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand cursor-pointer"
|
||||
>
|
||||
<option value="ALL">Todos os Projetos</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Folder className="w-3.5 h-3.5 text-brand absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
onChange={(val) => setSelectedProjectId(val)}
|
||||
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
|
||||
options={[
|
||||
{ value: 'ALL', label: 'Todos os Projetos' },
|
||||
...projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
color: p.color || '#0f766e',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
<div className="w-44">
|
||||
<CustomSelect
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as TaskStatus | 'ALL')}
|
||||
className="px-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand cursor-pointer"
|
||||
>
|
||||
<option value="ALL">Todos os Status</option>
|
||||
<option value="NOT_STARTED">A Fazer</option>
|
||||
<option value="IN_PROGRESS">Em Andamento</option>
|
||||
<option value="COMPLETED">Concluído</option>
|
||||
<option value="CANCELED">Cancelado</option>
|
||||
</select>
|
||||
onChange={(val) => setSelectedStatus(val as TaskStatus | 'ALL')}
|
||||
options={[
|
||||
{ value: 'ALL', label: 'Todos os Status' },
|
||||
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
|
||||
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
|
||||
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
|
||||
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue