Created
December 17, 2025 01:40
-
-
Save celsowm/0343324bb6753d58d3880c2b58155f1c to your computer and use it in GitHub Desktop.
pgedigital_mantine.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import '@mantine/core/styles.css'; | |
| import React, { useEffect, useMemo, useReducer, useState } from 'react'; | |
| import { | |
| ActionIcon, | |
| Avatar, | |
| Badge, | |
| Box, | |
| Button, | |
| Divider, | |
| Group, | |
| MantineProvider, | |
| Modal, | |
| Paper, | |
| Select, | |
| Stack, | |
| Table, | |
| Text, | |
| TextInput, | |
| Tooltip, | |
| UnstyledButton, | |
| createTheme, | |
| } from '@mantine/core'; | |
| import type { ButtonProps, ModalProps, SelectProps, TextInputProps } from '@mantine/core'; | |
| import { useDisclosure, useMediaQuery, usePagination } from '@mantine/hooks'; | |
| import { AnimatePresence, motion } from 'framer-motion'; | |
| import { | |
| ChevronLeft, | |
| LayoutDashboard, | |
| LogOut, | |
| Pencil, | |
| Search, | |
| Settings, | |
| Trash2, | |
| Users, | |
| X, | |
| Zap, | |
| } from 'lucide-react'; | |
| // ========================= | |
| // Domain | |
| // ========================= | |
| type Status = 'Online' | 'Ausente' | 'Ocupado' | 'Offline'; | |
| type Person = { | |
| name: string; | |
| email: string; | |
| role: string; | |
| status: Status; | |
| lastActive: string; | |
| }; | |
| type PersonErrors = Partial<Record<'name' | 'email' | 'role', string>> & { _?: string }; | |
| type PersonModalMode = 'add' | 'edit'; | |
| type ModalBtnTone = 'primary' | 'secondary' | 'danger'; | |
| type GlowTone = 'primary' | 'edit' | 'delete'; | |
| type SearchField = 'name' | 'email' | 'role' | 'status' | 'lastActive' | 'all'; | |
| const PAGE_SIZE = 10; | |
| const STATUS_OPTIONS: Status[] = ['Online', 'Ausente', 'Ocupado', 'Offline']; | |
| const SEARCH_FIELD_OPTIONS: Array<{ value: SearchField; label: string }> = [ | |
| { value: 'name', label: 'Nome' }, | |
| { value: 'email', label: 'Email' }, | |
| { value: 'role', label: 'Cargo' }, | |
| { value: 'status', label: 'Status' }, | |
| { value: 'lastActive', label: 'Última atividade' }, | |
| { value: 'all', label: 'Todos' }, | |
| ]; | |
| const STATUS_UI: Record<Status, { dot: string; text: string }> = { | |
| Online: { dot: 'rgba(5, 150, 105, 0.85)', text: 'rgba(15, 23, 42, 0.88)' }, | |
| Ausente: { dot: 'rgba(217, 119, 6, 0.85)', text: 'rgba(15, 23, 42, 0.88)' }, | |
| Ocupado: { dot: 'rgba(225, 29, 72, 0.80)', text: 'rgba(15, 23, 42, 0.88)' }, | |
| Offline: { dot: 'rgba(15, 23, 42, 0.35)', text: 'rgba(15, 23, 42, 0.80)' }, | |
| }; | |
| const INITIAL_PEOPLE: Person[] = [ | |
| { name: 'Ana Souza', email: 'ana.souza@exemplo.com', role: 'Product Designer', status: 'Online', lastActive: 'Agora' }, | |
| { name: 'Bruno Almeida', email: 'bruno.almeida@exemplo.com', role: 'Frontend Engineer', status: 'Online', lastActive: 'Há 5 min' }, | |
| { name: 'Carla Mendes', email: 'carla.mendes@exemplo.com', role: 'Data Analyst', status: 'Ausente', lastActive: 'Há 22 min' }, | |
| { name: 'Diego Lima', email: 'diego.lima@exemplo.com', role: 'Backend Engineer', status: 'Ocupado', lastActive: 'Há 1h' }, | |
| { name: 'Eduarda Ramos', email: 'eduarda.ramos@exemplo.com', role: 'QA Engineer', status: 'Offline', lastActive: 'Hoje' }, | |
| { name: 'Felipe Costa', email: 'felipe.costa@exemplo.com', role: 'DevOps', status: 'Online', lastActive: 'Hoje' }, | |
| { name: 'Gabriela Nunes', email: 'gabriela.nunes@exemplo.com', role: 'Product Manager', status: 'Ausente', lastActive: 'Ontem' }, | |
| { name: 'Henrique Silva', email: 'henrique.silva@exemplo.com', role: 'Mobile Engineer', status: 'Ocupado', lastActive: 'Há 2 dias' }, | |
| { name: 'Isabela Ferreira', email: 'isabela.ferreira@exemplo.com', role: 'Support', status: 'Offline', lastActive: 'Há 1 semana' }, | |
| { name: 'João Pereira', email: 'joao.pereira@exemplo.com', role: 'Tech Lead', status: 'Online', lastActive: 'Há 3 semanas' }, | |
| { name: 'Karina Rocha', email: 'karina.rocha@exemplo.com', role: 'UX Researcher', status: 'Ausente', lastActive: 'Há 3h' }, | |
| { name: 'Lucas Martins', email: 'lucas.martins@exemplo.com', role: 'Fullstack Engineer', status: 'Online', lastActive: 'Há 12 min' }, | |
| { name: 'Mariana Azevedo', email: 'mariana.azevedo@exemplo.com', role: 'Data Engineer', status: 'Ocupado', lastActive: 'Hoje' }, | |
| { name: 'Nicolas Barros', email: 'nicolas.barros@exemplo.com', role: 'Security', status: 'Offline', lastActive: 'Ontem' }, | |
| { name: 'Olívia Santos', email: 'olivia.santos@exemplo.com', role: 'Content', status: 'Online', lastActive: 'Agora' }, | |
| { name: 'Pedro Henrique', email: 'pedro.henrique@exemplo.com', role: 'SRE', status: 'Ocupado', lastActive: 'Há 40 min' }, | |
| { name: 'Quézia Duarte', email: 'quezia.duarte@exemplo.com', role: 'HR', status: 'Ausente', lastActive: 'Há 6h' }, | |
| { name: 'Renato Carvalho', email: 'renato.carvalho@exemplo.com', role: 'Backend Engineer', status: 'Online', lastActive: 'Hoje' }, | |
| { name: 'Sabrina Oliveira', email: 'sabrina.oliveira@exemplo.com', role: 'QA Lead', status: 'Offline', lastActive: 'Há 4 dias' }, | |
| { name: 'Thiago Ribeiro', email: 'thiago.ribeira@exemplo.com', role: 'Frontend Engineer', status: 'Online', lastActive: 'Há 1 dia' }, | |
| ]; | |
| function emptyPerson(): Person { | |
| return { name: '', email: '', role: '', status: 'Online', lastActive: 'Agora' }; | |
| } | |
| // ========================= | |
| // Helpers (pure) | |
| // ========================= | |
| function clamp(n: number, min: number, max: number) { | |
| return Math.min(Math.max(n, min), max); | |
| } | |
| function paginate<T>(items: T[], page: number, pageSize: number) { | |
| const total = items.length; | |
| const pageCount = Math.max(1, Math.ceil(total / pageSize)); | |
| const safePage = clamp(page, 1, pageCount); | |
| const start = (safePage - 1) * pageSize; | |
| const end = Math.min(start + pageSize, total); | |
| return { total, pageCount, safePage, start, end, slice: items.slice(start, end) }; | |
| } | |
| function validateRequiredFields(p: Person): PersonErrors { | |
| const errors: PersonErrors = {}; | |
| if (!p.name.trim()) errors.name = 'Informe o nome'; | |
| if (!p.email.trim()) errors.email = 'Informe o email'; | |
| if (!p.role.trim()) errors.role = 'Informe o cargo'; | |
| return errors; | |
| } | |
| function hasErrors(errors: PersonErrors) { | |
| return Object.keys(errors).length > 0; | |
| } | |
| function normalizeText(v: string) { | |
| return v | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .toLowerCase() | |
| .trim(); | |
| } | |
| // ========================= | |
| // Store (reducer) | |
| // ========================= | |
| type PeopleState = { people: Person[] }; | |
| type PeopleAction = | |
| | { type: 'ADD'; person: Person } | |
| | { type: 'UPDATE_BY_EMAIL'; email: string; person: Person } | |
| | { type: 'REMOVE_BY_EMAIL'; email: string }; | |
| function peopleReducer(state: PeopleState, action: PeopleAction): PeopleState { | |
| switch (action.type) { | |
| case 'ADD': | |
| return { people: [action.person, ...state.people] }; | |
| case 'UPDATE_BY_EMAIL': | |
| return { people: state.people.map((x) => (x.email === action.email ? action.person : x)) }; | |
| case 'REMOVE_BY_EMAIL': | |
| return { people: state.people.filter((x) => x.email !== action.email) }; | |
| default: | |
| return state; | |
| } | |
| } | |
| function usePeopleStore(initial: Person[]) { | |
| const [state, dispatch] = useReducer(peopleReducer, { people: initial }); | |
| const emailExists = (email: string, ignoreEmail?: string | null) => | |
| state.people.some((p) => p.email === email && p.email !== ignoreEmail); | |
| return { | |
| people: state.people, | |
| emailExists, | |
| add: (person: Person) => dispatch({ type: 'ADD', person }), | |
| updateByEmail: (email: string, person: Person) => dispatch({ type: 'UPDATE_BY_EMAIL', email, person }), | |
| removeByEmail: (email: string) => dispatch({ type: 'REMOVE_BY_EMAIL', email }), | |
| }; | |
| } | |
| // ========================= | |
| // Theme + glass tokens | |
| // ========================= | |
| const theme = createTheme({ | |
| primaryColor: 'blue', | |
| defaultRadius: 'md', | |
| fontFamily: | |
| "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'", | |
| headings: { | |
| fontFamily: | |
| "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'", | |
| }, | |
| }); | |
| const GLASS = { | |
| panel: { | |
| border: '1px solid rgba(255, 255, 255, 0.45)', | |
| background: 'rgba(255, 255, 255, 0.18)', | |
| boxShadow: '0 18px 60px rgba(0,0,0,0.12)', | |
| backdropFilter: 'blur(18px) saturate(150%)', | |
| }, | |
| panelSheenA: { | |
| background: | |
| 'linear-gradient(135deg, rgba(255,255,255,0.65), rgba(255,255,255,0.12), rgba(255,255,255,0))', | |
| opacity: 0.7, | |
| }, | |
| panelSheenB: { | |
| background: 'radial-gradient(900px 500px at 20% 0%, rgba(255,255,255,0.35), rgba(255,255,255,0) 60%)', | |
| opacity: 0.6, | |
| }, | |
| surface: { | |
| background: | |
| 'linear-gradient(135deg, rgba(255,255,255,0.62) 0%, rgba(255,255,255,0.24) 48%, rgba(255,255,255,0.16) 100%)', | |
| border: '1px solid rgba(255, 255, 255, 0.62)', | |
| boxShadow: '0 28px 90px rgba(0,0,0,0.18)', | |
| backdropFilter: 'blur(22px) saturate(175%)', | |
| }, | |
| tableWrap: { | |
| border: '1px solid rgba(255, 255, 255, 0.30)', | |
| background: 'rgba(255, 255, 255, 0.14)', | |
| }, | |
| footer: { | |
| borderTop: '1px solid rgba(255, 255, 255, 0.25)', | |
| background: 'rgba(255, 255, 255, 0.10)', | |
| }, | |
| } as const; | |
| const GLASS_MODAL_OVERLAY = { | |
| blur: 12, | |
| backgroundOpacity: 0.32, | |
| color: '#0b1220', | |
| } as const; | |
| const GLASS_MODAL_STYLES = { | |
| content: { | |
| ...GLASS.surface, | |
| borderRadius: 22, | |
| overflow: 'hidden', | |
| }, | |
| header: { | |
| background: 'transparent', | |
| borderBottom: '1px solid rgba(255, 255, 255, 0.22)', | |
| paddingInline: 18, | |
| paddingTop: 14, | |
| paddingBottom: 12, | |
| }, | |
| body: { | |
| paddingInline: 18, | |
| paddingTop: 14, | |
| paddingBottom: 16, | |
| }, | |
| } as const; | |
| function GlassModal(props: ModalProps) { | |
| return <Modal centered radius="xl" overlayProps={GLASS_MODAL_OVERLAY} styles={GLASS_MODAL_STYLES} {...props} />; | |
| } | |
| // ========================= | |
| // Background (blob) | |
| // ========================= | |
| const BLOB_BG_CSS = ` | |
| @keyframes blobA { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(70px, 30px, 0) scale(1.08); } } | |
| @keyframes blobB { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(-60px, 40px, 0) scale(1.10); } } | |
| @keyframes blobC { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(50px, -35px, 0) scale(1.06); } } | |
| @keyframes blobD { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(-40px, -25px, 0) scale(1.07); } } | |
| .blob-bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; } | |
| .blob { position: absolute; border-radius: 9999px; filter: blur(55px); will-change: transform; } | |
| .blob-a { animation: blobA 18s ease-in-out infinite alternate; } | |
| .blob-b { animation: blobB 22s ease-in-out infinite alternate; } | |
| .blob-c { animation: blobC 20s ease-in-out infinite alternate; } | |
| .blob-d { animation: blobD 24s ease-in-out infinite alternate; } | |
| @media (prefers-reduced-motion: reduce) { | |
| .blob-a, .blob-b, .blob-c, .blob-d { animation: none !important; } | |
| } | |
| `; | |
| function BlobBackground() { | |
| return ( | |
| <Box | |
| className="blob-bg" | |
| style={{ | |
| background: | |
| 'linear-gradient(120deg, rgba(18, 44, 92, 1) 0%, rgba(12, 76, 152, 1) 52%, rgba(14, 112, 170, 1) 100%)', | |
| }} | |
| > | |
| <Box | |
| className="blob blob-a" | |
| style={{ | |
| width: 980, | |
| height: 980, | |
| left: '-12%', | |
| top: '-28%', | |
| opacity: 0.9, | |
| background: | |
| 'radial-gradient(circle at 30% 30%, rgba(56, 132, 230, 0.82) 0%, rgba(56, 132, 230, 0) 62%)', | |
| mixBlendMode: 'multiply', | |
| }} | |
| /> | |
| <Box | |
| className="blob blob-c" | |
| style={{ | |
| width: 1120, | |
| height: 1120, | |
| left: '-14%', | |
| top: '14%', | |
| opacity: 0.74, | |
| background: | |
| 'radial-gradient(circle at 30% 30%, rgba(42, 110, 210, 0.60) 0%, rgba(42, 110, 210, 0) 66%)', | |
| mixBlendMode: 'multiply', | |
| }} | |
| /> | |
| <Box | |
| className="blob" | |
| style={{ | |
| width: 1320, | |
| height: 820, | |
| left: '0%', | |
| top: '-10%', | |
| filter: 'blur(75px)', | |
| opacity: 0.52, | |
| background: | |
| 'radial-gradient(circle at 35% 35%, rgba(48, 124, 220, 0.46) 0%, rgba(48, 124, 220, 0) 66%)', | |
| mixBlendMode: 'multiply', | |
| }} | |
| /> | |
| <Box | |
| className="blob blob-b" | |
| style={{ | |
| width: 680, | |
| height: 680, | |
| right: '-24%', | |
| top: '-20%', | |
| opacity: 0.8, | |
| background: | |
| 'radial-gradient(circle at 30% 30%, rgba(66, 230, 210, 0.72) 0%, rgba(66, 230, 210, 0) 62%)', | |
| mixBlendMode: 'multiply', | |
| }} | |
| /> | |
| <Box | |
| className="blob blob-d" | |
| style={{ | |
| width: 740, | |
| height: 740, | |
| right: '-28%', | |
| bottom: '-36%', | |
| opacity: 0.8, | |
| background: | |
| 'radial-gradient(circle at 30% 30%, rgba(52, 214, 202, 0.72) 0%, rgba(52, 214, 202, 0) 64%)', | |
| mixBlendMode: 'multiply', | |
| }} | |
| /> | |
| <Box | |
| style={{ | |
| position: 'absolute', | |
| left: '22%', | |
| top: '18%', | |
| width: 1040, | |
| height: 1040, | |
| borderRadius: 9999, | |
| filter: 'blur(70px)', | |
| opacity: 0.46, | |
| background: | |
| 'radial-gradient(circle at 30% 30%, rgba(40, 170, 190, 0.48) 0%, rgba(40, 170, 190, 0) 64%)', | |
| mixBlendMode: 'multiply', | |
| pointerEvents: 'none', | |
| }} | |
| /> | |
| <Box | |
| style={{ | |
| position: 'absolute', | |
| inset: 0, | |
| background: 'radial-gradient(980px 560px at 52% 26%, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0) 58%)', | |
| opacity: 0.26, | |
| }} | |
| /> | |
| </Box> | |
| ); | |
| } | |
| // ========================= | |
| // Floating label (DRY, predictable DOM) | |
| // ========================= | |
| const FLOATING_LABEL_CSS = ` | |
| .ff { position: relative; } | |
| .ff-label { | |
| position: absolute; | |
| left: 14px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 12px; | |
| font-weight: 650; | |
| color: rgba(15, 23, 42, 0.65); | |
| pointer-events: none; | |
| transition: transform 160ms ease, top 160ms ease, opacity 160ms ease, color 160ms ease; | |
| opacity: 0.95; | |
| } | |
| .ff-req { margin-left: 4px; color: rgba(225, 29, 72, 0.85); } | |
| .ff-input { | |
| height: 44px; | |
| border-radius: 14px; | |
| background: rgba(255, 255, 255, 0.24); | |
| border: 1px solid rgba(255, 255, 255, 0.52); | |
| backdrop-filter: blur(14px) saturate(160%); | |
| padding-top: 16px; | |
| transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; | |
| } | |
| .ff[data-floating] .ff-label { | |
| top: 10px; | |
| transform: translateY(0); | |
| opacity: 0.9; | |
| color: rgba(15, 23, 42, 0.62); | |
| } | |
| .ff .ff-input:focus { | |
| background: rgba(255, 255, 255, 0.34); | |
| border-color: rgba(14, 165, 233, 0.45); | |
| box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.14); | |
| } | |
| `; | |
| const SEARCH_INPUT_CSS = ` | |
| .search-input { | |
| height: 38px; | |
| border-radius: 9999px; | |
| background: rgba(255, 255, 255, 0.24); | |
| border: 1px solid rgba(255, 255, 255, 0.52); | |
| backdrop-filter: blur(14px) saturate(160%); | |
| transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; | |
| } | |
| .search-input:focus { | |
| background: rgba(255, 255, 255, 0.34); | |
| border-color: rgba(14, 165, 233, 0.45); | |
| box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.14); | |
| } | |
| `; | |
| function useFloatingLabel(value: unknown) { | |
| const [focused, setFocused] = useState(false); | |
| const str = value == null ? '' : String(value); | |
| const hasValue = str.trim().length !== 0; | |
| return { | |
| floating: focused || hasValue, | |
| onFocus: () => setFocused(true), | |
| onBlur: () => setFocused(false), | |
| }; | |
| } | |
| function joinClass(...parts: Array<string | undefined>) { | |
| return parts.filter(Boolean).join(' '); | |
| } | |
| function FloatingTextInput(props: TextInputProps) { | |
| const { label, required, withAsterisk, value, classNames, onFocus, onBlur, ...rest } = props; | |
| const id = React.useId(); | |
| const fl = useFloatingLabel(value); | |
| return ( | |
| <Box className="ff" data-floating={fl.floating ? true : undefined}> | |
| <Text component="label" htmlFor={id} className="ff-label"> | |
| {label} | |
| {(required || withAsterisk) && <span className="ff-req">*</span>} | |
| </Text> | |
| <TextInput | |
| {...rest} | |
| id={id} | |
| label={undefined} | |
| withAsterisk={false} | |
| value={value} | |
| placeholder="" | |
| classNames={{ | |
| ...classNames, | |
| input: joinClass('ff-input', classNames?.input), | |
| }} | |
| onFocus={(e) => { | |
| fl.onFocus(); | |
| onFocus?.(e); | |
| }} | |
| onBlur={(e) => { | |
| fl.onBlur(); | |
| onBlur?.(e); | |
| }} | |
| /> | |
| </Box> | |
| ); | |
| } | |
| function FloatingSelect(props: SelectProps) { | |
| const { label, required, withAsterisk, value, classNames, onFocus, onBlur, onDropdownOpen, onDropdownClose, ...rest } = | |
| props; | |
| const id = React.useId(); | |
| const fl = useFloatingLabel(value); | |
| return ( | |
| <Box className="ff" data-floating={fl.floating ? true : undefined}> | |
| <Text component="label" htmlFor={id} className="ff-label"> | |
| {label} | |
| {(required || withAsterisk) && <span className="ff-req">*</span>} | |
| </Text> | |
| <Select | |
| {...rest} | |
| id={id} | |
| label={undefined} | |
| withAsterisk={false} | |
| value={value} | |
| placeholder="" | |
| classNames={{ | |
| ...classNames, | |
| input: joinClass('ff-input', classNames?.input), | |
| }} | |
| onFocus={(e) => { | |
| fl.onFocus(); | |
| onFocus?.(e); | |
| }} | |
| onBlur={(e) => { | |
| fl.onBlur(); | |
| onBlur?.(e); | |
| }} | |
| onDropdownOpen={() => { | |
| fl.onFocus(); | |
| onDropdownOpen?.(); | |
| }} | |
| onDropdownClose={() => { | |
| fl.onBlur(); | |
| onDropdownClose?.(); | |
| }} | |
| /> | |
| </Box> | |
| ); | |
| } | |
| function SearchInput({ | |
| value, | |
| placeholder, | |
| onChange, | |
| onClear, | |
| }: { | |
| value: string; | |
| placeholder: string; | |
| onChange: (v: string) => void; | |
| onClear: () => void; | |
| }) { | |
| return ( | |
| <TextInput | |
| value={value} | |
| onChange={(e) => onChange(e.currentTarget.value)} | |
| placeholder={placeholder} | |
| leftSection={<Search size={16} />} | |
| leftSectionPointerEvents="none" | |
| rightSection={ | |
| value.trim() ? ( | |
| <ActionIcon variant="subtle" radius="xl" aria-label="Limpar pesquisa" title="Limpar" onClick={onClear}> | |
| <X size={16} /> | |
| </ActionIcon> | |
| ) : null | |
| } | |
| classNames={{ input: 'search-input' }} | |
| /> | |
| ); | |
| } | |
| function SearchFieldSelect({ value, onChange }: { value: SearchField; onChange: (v: SearchField) => void }) { | |
| return ( | |
| <Select | |
| value={value} | |
| onChange={(v) => onChange(((v as SearchField) ?? 'name') as SearchField)} | |
| data={SEARCH_FIELD_OPTIONS} | |
| allowDeselect={false} | |
| comboboxProps={{ withinPortal: true }} | |
| classNames={{ input: 'search-input' }} | |
| /> | |
| ); | |
| } | |
| function StatusSearchSelect({ value, onChange }: { value: Status | ''; onChange: (v: Status | '') => void }) { | |
| return ( | |
| <Select | |
| value={value || null} | |
| onChange={(v) => onChange(((v as Status) ?? '') as Status | '')} | |
| data={STATUS_OPTIONS} | |
| placeholder="Selecione o status" | |
| clearable | |
| comboboxProps={{ withinPortal: true }} | |
| classNames={{ input: 'search-input' }} | |
| /> | |
| ); | |
| } | |
| // ========================= | |
| // Glass interactive controls (hover via CSS) | |
| // ========================= | |
| function glowTone(tone: GlowTone) { | |
| if (tone === 'delete') return { accent: 'rgb(190, 18, 60)', ring: 'rgba(244, 63, 94, 0.25)' }; | |
| return { accent: 'rgb(3, 105, 161)', ring: 'rgba(14, 165, 233, 0.25)' }; | |
| } | |
| function glowRootStyles(tone: GlowTone, opts?: { active?: boolean; disabled?: boolean }) { | |
| const { accent, ring } = glowTone(tone); | |
| const active = Boolean(opts?.active); | |
| const disabled = Boolean(opts?.disabled); | |
| const baseBg = active ? 'rgba(255, 255, 255, 0.35)' : 'rgba(255, 255, 255, 0.18)'; | |
| return { | |
| border: active ? '1px solid rgba(255, 255, 255, 0.55)' : '1px solid rgba(255, 255, 255, 0.45)', | |
| background: baseBg, | |
| color: active ? accent : 'rgba(15, 23, 42, 0.80)', | |
| backdropFilter: 'blur(12px)', | |
| cursor: disabled ? 'not-allowed' : 'pointer', | |
| transition: | |
| 'background-color 160ms ease, color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease', | |
| willChange: 'transform, box-shadow', | |
| outline: '2px solid transparent', | |
| outlineOffset: 2, | |
| boxShadow: active ? '0 10px 24px rgba(0,0,0,0.08)' : '0 0 0 rgba(0,0,0,0)', | |
| ...(disabled | |
| ? { opacity: 0.45 } | |
| : { | |
| '&:hover': { | |
| background: 'rgba(255, 255, 255, 0.28)', | |
| color: accent, | |
| transform: 'translateY(-1px) scale(1.03)', | |
| boxShadow: '0 10px 24px rgba(0,0,0,0.08)', | |
| }, | |
| '&:focus-visible': { | |
| outline: `2px solid ${ring}`, | |
| }, | |
| }), | |
| } as const; | |
| } | |
| function GlowActionIcon({ | |
| ariaLabel, | |
| tone, | |
| onClick, | |
| children, | |
| }: { | |
| ariaLabel: string; | |
| tone: Exclude<GlowTone, 'primary'>; | |
| onClick: () => void; | |
| children: React.ReactNode; | |
| }) { | |
| const { accent, ring } = glowTone(tone); | |
| return ( | |
| <UnstyledButton | |
| type="button" | |
| aria-label={ariaLabel} | |
| title={ariaLabel} | |
| onClick={onClick} | |
| className="ga" | |
| style={{ ['--accent' as any]: accent, ['--ring' as any]: ring } as React.CSSProperties} | |
| > | |
| <svg className="ga-ring" viewBox="0 0 40 40" aria-hidden="true"> | |
| <circle className="ga-ring-circle" cx="20" cy="20" r="18" /> | |
| </svg> | |
| <Box | |
| style={{ | |
| lineHeight: 0, | |
| display: 'grid', | |
| placeItems: 'center', | |
| color: 'inherit', | |
| position: 'relative', | |
| zIndex: 1, | |
| }} | |
| > | |
| {children} | |
| </Box> | |
| </UnstyledButton> | |
| ); | |
| } | |
| function GlowButton({ onClick, children, ariaLabel }: { onClick: () => void; children: React.ReactNode; ariaLabel?: string }) { | |
| const { accent, ring } = glowTone('primary'); | |
| return ( | |
| <UnstyledButton | |
| type="button" | |
| aria-label={ariaLabel} | |
| title={ariaLabel} | |
| onClick={onClick} | |
| className="gb" | |
| style={{ ['--accent' as any]: accent, ['--ring' as any]: ring } as React.CSSProperties} | |
| > | |
| <svg className="gb-ring" viewBox="0 0 160 44" preserveAspectRatio="none" aria-hidden="true"> | |
| <rect className="gb-ring-rect" x="2" y="2" width="156" height="40" rx="20" /> | |
| </svg> | |
| <span className="gb-label">{children}</span> | |
| </UnstyledButton> | |
| ); | |
| } | |
| const MODAL_BTN_CSS = ` | |
| .mb { | |
| height: 40px; | |
| padding: 0 18px; | |
| border-radius: 9999px; | |
| font-weight: 750; | |
| letter-spacing: -0.01em; | |
| backdrop-filter: blur(14px) saturate(160%); | |
| transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease, color 160ms ease; | |
| will-change: transform, box-shadow; | |
| } | |
| .mb:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| transform: none !important; | |
| box-shadow: none !important; | |
| } | |
| .mb-secondary { | |
| background: rgba(255, 255, 255, 0.18) !important; | |
| border: 1px solid rgba(255, 255, 255, 0.55) !important; | |
| color: rgba(15, 23, 42, 0.84) !important; | |
| box-shadow: 0 10px 24px rgba(0,0,0,0.08); | |
| } | |
| .mb-secondary:hover:not(:disabled) { | |
| background: rgba(255, 255, 255, 0.28) !important; | |
| border-color: rgba(255, 255, 255, 0.62) !important; | |
| transform: translateY(-1px); | |
| } | |
| .mb-secondary:focus-visible { | |
| outline: 2px solid rgba(14, 165, 233, 0.18); | |
| outline-offset: 2px; | |
| } | |
| .mb-primary { | |
| background: linear-gradient(135deg, rgba(14, 165, 233, 0.92) 0%, rgba(3, 105, 161, 0.92) 100%) !important; | |
| border: 1px solid rgba(255, 255, 255, 0.26) !important; | |
| color: rgba(255,255,255,0.96) !important; | |
| box-shadow: 0 16px 36px rgba(3, 105, 161, 0.22); | |
| } | |
| .mb-primary:hover:not(:disabled) { | |
| transform: translateY(-1px) scale(1.01); | |
| box-shadow: 0 18px 42px rgba(3, 105, 161, 0.26); | |
| } | |
| .mb-primary:focus-visible { | |
| outline: 2px solid rgba(14, 165, 233, 0.28); | |
| outline-offset: 2px; | |
| } | |
| .mb-danger { | |
| background: linear-gradient(135deg, rgba(244, 63, 94, 0.92) 0%, rgba(190, 18, 60, 0.92) 100%) !important; | |
| border: 1px solid rgba(255, 255, 255, 0.26) !important; | |
| color: rgba(255,255,255,0.96) !important; | |
| box-shadow: 0 16px 34px rgba(190, 18, 60, 0.22); | |
| } | |
| .mb-danger:hover:not(:disabled) { | |
| transform: translateY(-1px) scale(1.01); | |
| box-shadow: 0 18px 40px rgba(190, 18, 60, 0.26); | |
| } | |
| .mb-danger:focus-visible { | |
| outline: 2px solid rgba(244, 63, 94, 0.28); | |
| outline-offset: 2px; | |
| } | |
| `; | |
| const ACTION_BTN_CSS = ` | |
| .ga { | |
| position: relative; | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 9999px; | |
| display: grid; | |
| place-items: center; | |
| background: rgba(255, 255, 255, 0.18); | |
| border: 1px solid rgba(255, 255, 255, 0.45); | |
| color: rgba(15, 23, 42, 0.80); | |
| backdrop-filter: blur(12px); | |
| transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; | |
| will-change: transform, box-shadow; | |
| outline: 2px solid transparent; | |
| outline-offset: 2px; | |
| } | |
| .ga:hover { | |
| background: rgba(255, 255, 255, 0.28); | |
| color: var(--accent); | |
| transform: translateY(-1px) scale(1.03); | |
| box-shadow: 0 10px 24px rgba(0,0,0,0.08); | |
| } | |
| .ga:focus-visible { | |
| outline: 2px solid var(--ring); | |
| } | |
| .ga svg, | |
| .ga svg * { | |
| stroke: currentColor; | |
| } | |
| .ga-ring { | |
| position: absolute; | |
| inset: -3px; | |
| width: calc(100% + 6px); | |
| height: calc(100% + 6px); | |
| pointer-events: none; | |
| opacity: 0; | |
| filter: drop-shadow(0 6px 14px rgba(0,0,0,0.08)); | |
| } | |
| .ga-ring-circle { | |
| fill: none; | |
| stroke: var(--accent); | |
| stroke-width: 2; | |
| stroke-linecap: round; | |
| stroke-dasharray: 113; | |
| stroke-dashoffset: 113; | |
| transition: stroke-dashoffset 260ms ease, opacity 120ms ease; | |
| } | |
| .ga:hover .ga-ring { opacity: 1; } | |
| .ga:hover .ga-ring-circle { stroke-dashoffset: 0; } | |
| @media (prefers-reduced-motion: reduce) { | |
| .ga, .ga-ring-circle { transition: none !important; } | |
| .ga-ring-circle { stroke-dashoffset: 0; } | |
| } | |
| `; | |
| const ADD_BTN_CSS = ` | |
| .gb { | |
| position: relative; | |
| height: 38px; | |
| padding: 0 14px; | |
| border-radius: 9999px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| background: rgba(255, 255, 255, 0.18); | |
| border: 1px solid rgba(255, 255, 255, 0.45); | |
| color: rgba(15, 23, 42, 0.82); | |
| backdrop-filter: blur(12px); | |
| transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; | |
| will-change: transform, box-shadow; | |
| outline: 2px solid transparent; | |
| outline-offset: 2px; | |
| } | |
| .gb-label { | |
| font-size: 14px; | |
| font-weight: 650; | |
| line-height: 1; | |
| } | |
| .gb:hover { | |
| background: rgba(255, 255, 255, 0.28); | |
| color: var(--accent); | |
| transform: translateY(-1px) scale(1.01); | |
| box-shadow: 0 14px 32px rgba(0,0,0,0.10); | |
| } | |
| .gb:focus-visible { | |
| outline: 2px solid var(--ring); | |
| } | |
| .gb-ring { | |
| position: absolute; | |
| inset: -4px; | |
| width: calc(100% + 8px); | |
| height: calc(100% + 8px); | |
| pointer-events: none; | |
| opacity: 0; | |
| filter: drop-shadow(0 10px 24px rgba(0,0,0,0.10)); | |
| } | |
| .gb-ring-rect { | |
| fill: none; | |
| stroke: var(--accent); | |
| stroke-width: 2; | |
| stroke-linecap: round; | |
| stroke-dasharray: 440; | |
| stroke-dashoffset: 440; | |
| transition: stroke-dashoffset 300ms ease, opacity 120ms ease; | |
| } | |
| .gb:hover .gb-ring { opacity: 1; } | |
| .gb:hover .gb-ring-rect { stroke-dashoffset: 0; } | |
| @media (prefers-reduced-motion: reduce) { | |
| .gb, .gb-ring-rect { transition: none !important; } | |
| .gb-ring-rect { stroke-dashoffset: 0; } | |
| } | |
| `; | |
| function ModalButton({ tone, className, children, title, ...props }: ButtonProps & { tone: ModalBtnTone }) { | |
| const toneClass = tone === 'primary' ? 'mb-primary' : tone === 'danger' ? 'mb-danger' : 'mb-secondary'; | |
| const inferredTitle = | |
| title ?? (typeof children === 'string' || typeof children === 'number' ? String(children) : undefined); | |
| return ( | |
| <Button | |
| {...props} | |
| title={inferredTitle} | |
| variant="default" | |
| className={['mb', toneClass, className].filter(Boolean).join(' ')} | |
| styles={{ label: { lineHeight: 1 } }} | |
| > | |
| {children} | |
| </Button> | |
| ); | |
| } | |
| // ========================= | |
| // UI atoms | |
| // ========================= | |
| function StatusPill({ status }: { status: Status }) { | |
| const ui = STATUS_UI[status]; | |
| return ( | |
| <Badge | |
| radius="xl" | |
| variant="outline" | |
| styles={{ | |
| root: { | |
| background: 'rgba(255, 255, 255, 0.18)', | |
| border: '1px solid rgba(255, 255, 255, 0.35)', | |
| color: ui.text, | |
| textTransform: 'none', | |
| fontWeight: 650, | |
| paddingInline: 10, | |
| height: 28, | |
| backdropFilter: 'blur(10px)', | |
| }, | |
| label: { display: 'inline-flex', alignItems: 'center', gap: 8 }, | |
| }} | |
| leftSection={<Box w={8} h={8} style={{ borderRadius: 9999, background: ui.dot }} />} | |
| > | |
| {status} | |
| </Badge> | |
| ); | |
| } | |
| function PagerControl({ | |
| ariaLabel, | |
| disabled, | |
| active, | |
| onClick, | |
| children, | |
| }: { | |
| ariaLabel: string; | |
| disabled?: boolean; | |
| active?: boolean; | |
| onClick: () => void; | |
| children: React.ReactNode; | |
| }) { | |
| return ( | |
| <ActionIcon | |
| variant="outline" | |
| radius="xl" | |
| size={36} | |
| aria-label={ariaLabel} | |
| title={ariaLabel} | |
| disabled={disabled} | |
| onClick={onClick} | |
| styles={{ root: glowRootStyles('primary', { active, disabled }) }} | |
| > | |
| {children} | |
| </ActionIcon> | |
| ); | |
| } | |
| // ========================= | |
| // Modals | |
| // ========================= | |
| type PersonModalProps = { | |
| opened: boolean; | |
| mode: PersonModalMode; | |
| initial: Person; | |
| errors: PersonErrors; | |
| onClose: () => void; | |
| onSubmit: (person: Person) => void; | |
| onClearError: (key: keyof PersonErrors) => void; | |
| }; | |
| function PersonModal({ opened, mode, initial, errors, onClose, onSubmit, onClearError }: PersonModalProps) { | |
| const [draft, setDraft] = useState<Person>(initial); | |
| useEffect(() => { | |
| setDraft(initial); | |
| }, [initial, opened]); | |
| const title = mode === 'edit' ? 'Editar pessoa' : 'Adicionar pessoa'; | |
| return ( | |
| <GlassModal opened={opened} onClose={onClose} title={<Text fw={700}>{title}</Text>} size="md"> | |
| <Stack gap={12} pt={2}> | |
| {errors._ && ( | |
| <Text fz="sm" c="red" fw={600}> | |
| {errors._} | |
| </Text> | |
| )} | |
| <Stack gap={12}> | |
| <FloatingTextInput | |
| label="Nome" | |
| value={draft.name} | |
| required | |
| error={errors.name} | |
| onChange={(e) => { | |
| onClearError('name'); | |
| setDraft((s) => ({ ...s, name: e.currentTarget.value })); | |
| }} | |
| /> | |
| <FloatingTextInput | |
| label="Email" | |
| value={draft.email} | |
| required | |
| error={errors.email} | |
| onChange={(e) => { | |
| onClearError('email'); | |
| setDraft((s) => ({ ...s, email: e.currentTarget.value })); | |
| }} | |
| /> | |
| <FloatingTextInput | |
| label="Cargo" | |
| value={draft.role} | |
| required | |
| error={errors.role} | |
| onChange={(e) => { | |
| onClearError('role'); | |
| setDraft((s) => ({ ...s, role: e.currentTarget.value })); | |
| }} | |
| /> | |
| <FloatingSelect | |
| label="Status" | |
| data={STATUS_OPTIONS} | |
| value={draft.status} | |
| onChange={(v) => setDraft((s) => ({ ...s, status: ((v as Status) ?? 'Online') as Status }))} | |
| allowDeselect={false} | |
| /> | |
| <FloatingTextInput | |
| label="Última atividade" | |
| value={draft.lastActive} | |
| onChange={(e) => setDraft((s) => ({ ...s, lastActive: e.currentTarget.value }))} | |
| /> | |
| </Stack> | |
| <Divider color="rgba(255, 255, 255, 0.22)" /> | |
| <Group justify="flex-end" gap={10}> | |
| <ModalButton tone="secondary" onClick={onClose}> | |
| Cancelar | |
| </ModalButton> | |
| <ModalButton tone="primary" onClick={() => onSubmit(draft)}> | |
| {mode === 'edit' ? 'Salvar' : 'Adicionar'} | |
| </ModalButton> | |
| </Group> | |
| </Stack> | |
| </GlassModal> | |
| ); | |
| } | |
| type ConfirmModalProps = { | |
| opened: boolean; | |
| title: string; | |
| description: string; | |
| confirmLabel: string; | |
| onClose: () => void; | |
| onConfirm: () => void; | |
| }; | |
| function ConfirmModal({ opened, title, description, confirmLabel, onClose, onConfirm }: ConfirmModalProps) { | |
| return ( | |
| <GlassModal opened={opened} onClose={onClose} title={<Text fw={700}>{title}</Text>} size="sm"> | |
| <Text c="dimmed" fz="sm"> | |
| {description} | |
| </Text> | |
| <Group justify="flex-end" mt="md" gap={10}> | |
| <ModalButton tone="secondary" onClick={onClose}> | |
| Cancelar | |
| </ModalButton> | |
| <ModalButton tone="danger" onClick={onConfirm}> | |
| {confirmLabel} | |
| </ModalButton> | |
| </Group> | |
| </GlassModal> | |
| ); | |
| } | |
| // ========================= | |
| // Table | |
| // ========================= | |
| type PeopleTableProps = { | |
| items: Person[]; | |
| showDesktopColumns: boolean; | |
| onEdit: (p: Person) => void; | |
| onDelete: (p: Person) => void; | |
| }; | |
| function PeopleTable({ items, showDesktopColumns, onEdit, onDelete }: PeopleTableProps) { | |
| if (items.length === 0) { | |
| const colSpan = showDesktopColumns ? 6 : 4; | |
| return ( | |
| <Table | |
| striped="odd" | |
| highlightOnHover | |
| withTableBorder={false} | |
| withColumnBorders={false} | |
| withRowBorders | |
| borderColor="rgba(255, 255, 255, 0.22)" | |
| stripedColor="rgba(255, 255, 255, 0.08)" | |
| highlightOnHoverColor="rgba(255, 255, 255, 0.18)" | |
| horizontalSpacing="md" | |
| verticalSpacing="sm" | |
| fz="sm" | |
| styles={{ | |
| th: { | |
| background: 'rgba(255, 255, 255, 0.40)', | |
| backdropFilter: 'blur(14px) saturate(160%)', | |
| borderBottom: '1px solid rgba(255, 255, 255, 0.52)', | |
| boxShadow: 'inset 0 -1px 0 rgba(0,0,0,0.04)', | |
| fontSize: 11, | |
| fontWeight: 750, | |
| letterSpacing: '0.12em', | |
| textTransform: 'uppercase', | |
| color: 'rgba(15, 23, 42, 0.78)', | |
| }, | |
| td: { color: 'rgba(15, 23, 42, 0.90)' }, | |
| }} | |
| > | |
| <Table.Thead> | |
| <Table.Tr> | |
| <Table.Th>Nome</Table.Th> | |
| {showDesktopColumns && <Table.Th>Email</Table.Th>} | |
| <Table.Th>Cargo</Table.Th> | |
| <Table.Th>Status</Table.Th> | |
| {showDesktopColumns && <Table.Th>Última atividade</Table.Th>} | |
| <Table.Th w={160}>Ações</Table.Th> | |
| </Table.Tr> | |
| </Table.Thead> | |
| <Table.Tbody> | |
| <Table.Tr> | |
| <Table.Td colSpan={colSpan}> | |
| <Text ta="center" py="md" fz="sm" c="dimmed" fw={600}> | |
| Nenhum resultado. | |
| </Text> | |
| </Table.Td> | |
| </Table.Tr> | |
| </Table.Tbody> | |
| </Table> | |
| ); | |
| } | |
| return ( | |
| <Table | |
| striped="odd" | |
| highlightOnHover | |
| withTableBorder={false} | |
| withColumnBorders={false} | |
| withRowBorders | |
| borderColor="rgba(255, 255, 255, 0.22)" | |
| stripedColor="rgba(255, 255, 255, 0.08)" | |
| highlightOnHoverColor="rgba(255, 255, 255, 0.18)" | |
| horizontalSpacing="md" | |
| verticalSpacing="sm" | |
| fz="sm" | |
| styles={{ | |
| th: { | |
| background: 'rgba(255, 255, 255, 0.40)', | |
| backdropFilter: 'blur(14px) saturate(160%)', | |
| borderBottom: '1px solid rgba(255, 255, 255, 0.52)', | |
| boxShadow: 'inset 0 -1px 0 rgba(0,0,0,0.04)', | |
| fontSize: 11, | |
| fontWeight: 750, | |
| letterSpacing: '0.12em', | |
| textTransform: 'uppercase', | |
| color: 'rgba(15, 23, 42, 0.78)', | |
| }, | |
| td: { color: 'rgba(15, 23, 42, 0.90)' }, | |
| }} | |
| > | |
| <Table.Thead> | |
| <Table.Tr> | |
| <Table.Th>Nome</Table.Th> | |
| {showDesktopColumns && <Table.Th>Email</Table.Th>} | |
| <Table.Th>Cargo</Table.Th> | |
| <Table.Th>Status</Table.Th> | |
| {showDesktopColumns && <Table.Th>Última atividade</Table.Th>} | |
| <Table.Th w={160}>Ações</Table.Th> | |
| </Table.Tr> | |
| </Table.Thead> | |
| <Table.Tbody> | |
| {items.map((p) => ( | |
| <Table.Tr key={p.email}> | |
| <Table.Td>{p.name}</Table.Td> | |
| {showDesktopColumns && <Table.Td style={{ color: 'rgba(15, 23, 42, 0.80)' }}>{p.email}</Table.Td>} | |
| <Table.Td style={{ color: 'rgba(15, 23, 42, 0.90)' }}>{p.role}</Table.Td> | |
| <Table.Td> | |
| <StatusPill status={p.status} /> | |
| </Table.Td> | |
| {showDesktopColumns && <Table.Td style={{ color: 'rgba(15, 23, 42, 0.80)' }}>{p.lastActive}</Table.Td>} | |
| <Table.Td> | |
| <Group gap={8} wrap="nowrap"> | |
| <GlowActionIcon ariaLabel={`Editar ${p.name}`} tone="edit" onClick={() => onEdit(p)}> | |
| <Pencil size={16} /> | |
| </GlowActionIcon> | |
| <GlowActionIcon ariaLabel={`Remover ${p.name}`} tone="delete" onClick={() => onDelete(p)}> | |
| <Trash2 size={16} /> | |
| </GlowActionIcon> | |
| </Group> | |
| </Table.Td> | |
| </Table.Tr> | |
| ))} | |
| </Table.Tbody> | |
| </Table> | |
| ); | |
| } | |
| // ========================= | |
| // Sidebar Component | |
| // ========================= | |
| type NavItem = { | |
| id: string; | |
| label: string; | |
| icon: React.ReactNode; | |
| badge?: number; | |
| }; | |
| const NAV_ITEMS: NavItem[] = [ | |
| { id: 'pessoas', label: 'Pessoas', icon: <Users size={20} /> }, | |
| { id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={20} />, badge: 3 }, | |
| { id: 'configuracoes', label: 'Configurações', icon: <Settings size={20} /> }, | |
| ]; | |
| const SIDEBAR_CSS = ` | |
| .sidebar-nav-item { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| height: 44px; | |
| padding: 0 12px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: rgba(15, 23, 42, 0.75); | |
| background: transparent; | |
| border: 1px solid transparent; | |
| cursor: pointer; | |
| transition: all 180ms ease; | |
| overflow: hidden; | |
| } | |
| .sidebar-nav-item::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 12px; | |
| background: linear-gradient(135deg, rgba(255,255,255,0.12), rgba(255,255,255,0.04)); | |
| opacity: 0; | |
| transition: opacity 180ms ease; | |
| } | |
| .sidebar-nav-item:hover { | |
| color: rgba(15, 23, 42, 0.92); | |
| background: rgba(255, 255, 255, 0.22); | |
| border-color: rgba(255, 255, 255, 0.35); | |
| transform: translateX(2px); | |
| } | |
| .sidebar-nav-item:hover::before { | |
| opacity: 1; | |
| } | |
| .sidebar-nav-item[data-active="true"] { | |
| color: rgb(3, 105, 161); | |
| background: linear-gradient(135deg, rgba(14, 165, 233, 0.15), rgba(3, 105, 161, 0.08)); | |
| border-color: rgba(14, 165, 233, 0.35); | |
| box-shadow: 0 4px 16px rgba(14, 165, 233, 0.12); | |
| } | |
| .sidebar-nav-item[data-active="true"]::after { | |
| content: ''; | |
| position: absolute; | |
| left: 0; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| width: 3px; | |
| height: 24px; | |
| border-radius: 0 4px 4px 0; | |
| background: linear-gradient(180deg, rgba(14, 165, 233, 0.9), rgba(3, 105, 161, 0.9)); | |
| } | |
| .sidebar-nav-icon { | |
| display: grid; | |
| place-items: center; | |
| flex-shrink: 0; | |
| width: 20px; | |
| height: 20px; | |
| transition: transform 180ms ease; | |
| } | |
| .sidebar-nav-item:hover .sidebar-nav-icon { | |
| transform: scale(1.08); | |
| } | |
| .sidebar-toggle { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 10px; | |
| display: grid; | |
| place-items: center; | |
| background: rgba(255, 255, 255, 0.18); | |
| border: 1px solid rgba(255, 255, 255, 0.40); | |
| color: rgba(15, 23, 42, 0.70); | |
| cursor: pointer; | |
| transition: all 180ms ease; | |
| } | |
| .sidebar-toggle:hover { | |
| background: rgba(255, 255, 255, 0.30); | |
| border-color: rgba(255, 255, 255, 0.55); | |
| color: rgba(15, 23, 42, 0.90); | |
| transform: scale(1.05); | |
| } | |
| .sidebar-badge { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 20px; | |
| height: 20px; | |
| padding: 0 6px; | |
| border-radius: 10px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, rgba(14, 165, 233, 0.85), rgba(3, 105, 161, 0.85)); | |
| color: white; | |
| box-shadow: 0 2px 8px rgba(14, 165, 233, 0.30); | |
| } | |
| .sidebar-divider { | |
| height: 1px; | |
| margin: 8px 12px; | |
| background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent); | |
| } | |
| .sidebar-section-label { | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: rgba(15, 23, 42, 0.45); | |
| padding: 8px 14px 4px; | |
| } | |
| .sidebar-user { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px; | |
| border-radius: 14px; | |
| background: rgba(255, 255, 255, 0.10); | |
| border: 1px solid rgba(255, 255, 255, 0.25); | |
| cursor: pointer; | |
| transition: all 180ms ease; | |
| } | |
| .sidebar-user:hover { | |
| background: rgba(255, 255, 255, 0.18); | |
| border-color: rgba(255, 255, 255, 0.40); | |
| } | |
| .sidebar-user-info { | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| } | |
| .sidebar-user-name { | |
| font-size: 13px; | |
| font-weight: 650; | |
| color: rgba(15, 23, 42, 0.88); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .sidebar-user-email { | |
| font-size: 11px; | |
| color: rgba(15, 23, 42, 0.55); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .sidebar-logout { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 8px; | |
| display: grid; | |
| place-items: center; | |
| color: rgba(15, 23, 42, 0.50); | |
| transition: all 180ms ease; | |
| } | |
| .sidebar-logout:hover { | |
| background: rgba(244, 63, 94, 0.12); | |
| color: rgb(190, 18, 60); | |
| } | |
| `; | |
| function Sidebar({ | |
| isOpen, | |
| activeItem, | |
| onToggle, | |
| onNavigate, | |
| }: { | |
| isOpen: boolean; | |
| activeItem: string; | |
| onToggle: () => void; | |
| onNavigate: (id: string) => void; | |
| }) { | |
| const sidebarWidth = isOpen ? 260 : 72; | |
| return ( | |
| <motion.div | |
| initial={false} | |
| animate={{ width: sidebarWidth }} | |
| transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }} | |
| style={{ | |
| height: '100vh', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| ...GLASS.panel, | |
| borderRadius: 0, | |
| borderLeft: 'none', | |
| borderTop: 'none', | |
| borderBottom: 'none', | |
| position: 'relative', | |
| overflow: 'visible', | |
| }} | |
| > | |
| {/* Sheen overlay */} | |
| <Box | |
| style={{ | |
| position: 'absolute', | |
| inset: 0, | |
| background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0) 40%)', | |
| pointerEvents: 'none', | |
| }} | |
| /> | |
| {/* Header / Logo area */} | |
| <Box | |
| style={{ | |
| paddingTop: 20, | |
| paddingBottom: 16, | |
| paddingLeft: isOpen ? 16 : 12, | |
| paddingRight: isOpen ? 56 : 12, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: isOpen ? 12 : 0, | |
| borderBottom: '1px solid rgba(255, 255, 255, 0.20)', | |
| position: 'relative', | |
| zIndex: 2, | |
| }} | |
| > | |
| <Box | |
| style={{ | |
| width: isOpen ? 40 : 32, | |
| height: isOpen ? 40 : 32, | |
| borderRadius: 12, | |
| background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.85), rgba(3, 105, 161, 0.85))', | |
| display: 'grid', | |
| placeItems: 'center', | |
| boxShadow: '0 8px 24px rgba(3, 105, 161, 0.25)', | |
| flexShrink: 0, | |
| }} | |
| > | |
| <Zap size={isOpen ? 22 : 18} color="white" /> | |
| </Box> | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0, x: -10 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: -10 }} | |
| transition={{ duration: 0.2 }} | |
| style={{ flex: 1, minWidth: 0 }} | |
| > | |
| <Text | |
| fw={800} | |
| fz="lg" | |
| style={{ | |
| background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(15, 23, 42, 0.70))', | |
| WebkitBackgroundClip: 'text', | |
| WebkitTextFillColor: 'transparent', | |
| letterSpacing: '-0.02em', | |
| }} | |
| > | |
| PGE Digital | |
| </Text> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| <UnstyledButton | |
| className="sidebar-toggle" | |
| onClick={onToggle} | |
| aria-label={isOpen ? 'Recolher menu' : 'Expandir menu'} | |
| style={{ | |
| position: 'absolute', | |
| top: '50%', | |
| transform: 'translateY(-50%)', | |
| right: isOpen ? 16 : -14, | |
| zIndex: 5, | |
| }} | |
| > | |
| <motion.div | |
| animate={{ rotate: isOpen ? 0 : 180 }} | |
| transition={{ duration: 0.25 }} | |
| style={{ display: 'grid', placeItems: 'center' }} | |
| > | |
| <ChevronLeft size={18} /> | |
| </motion.div> | |
| </UnstyledButton> | |
| </Box> | |
| {/* Navigation */} | |
| <Box | |
| style={{ | |
| flex: 1, | |
| padding: isOpen ? '12px 12px' : '12px 8px', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 4, | |
| overflowY: 'auto', | |
| overflowX: 'hidden', | |
| position: 'relative', | |
| zIndex: 1, | |
| }} | |
| > | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.15 }} | |
| className="sidebar-section-label" | |
| > | |
| Menu principal | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {NAV_ITEMS.map((item) => { | |
| const isActive = activeItem === item.id; | |
| const button = ( | |
| <UnstyledButton | |
| key={item.id} | |
| className="sidebar-nav-item" | |
| data-active={isActive} | |
| onClick={() => onNavigate(item.id)} | |
| style={{ | |
| justifyContent: isOpen ? 'flex-start' : 'center', | |
| paddingInline: isOpen ? 12 : 0, | |
| }} | |
| > | |
| <span className="sidebar-nav-icon">{item.icon}</span> | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <motion.span | |
| initial={{ opacity: 0, x: -8 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: -8 }} | |
| transition={{ duration: 0.15 }} | |
| style={{ flex: 1, whiteSpace: 'nowrap' }} | |
| > | |
| {item.label} | |
| </motion.span> | |
| )} | |
| </AnimatePresence> | |
| <AnimatePresence> | |
| {isOpen && item.badge && ( | |
| <motion.span | |
| initial={{ opacity: 0, scale: 0.8 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.8 }} | |
| transition={{ duration: 0.15 }} | |
| className="sidebar-badge" | |
| > | |
| {item.badge} | |
| </motion.span> | |
| )} | |
| </AnimatePresence> | |
| </UnstyledButton> | |
| ); | |
| if (!isOpen) { | |
| return ( | |
| <Tooltip key={item.id} label={item.label} position="right" withArrow arrowSize={6} offset={12}> | |
| <Box style={{ position: 'relative' }}> | |
| {button} | |
| {item.badge && ( | |
| <Box | |
| style={{ | |
| position: 'absolute', | |
| top: 6, | |
| right: 6, | |
| width: 8, | |
| height: 8, | |
| borderRadius: 4, | |
| background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.9), rgba(3, 105, 161, 0.9))', | |
| boxShadow: '0 2px 6px rgba(14, 165, 233, 0.40)', | |
| }} | |
| /> | |
| )} | |
| </Box> | |
| </Tooltip> | |
| ); | |
| } | |
| return button; | |
| })} | |
| </Box> | |
| {/* Divider */} | |
| <Box className="sidebar-divider" style={{ margin: isOpen ? '8px 12px' : '8px 8px' }} /> | |
| {/* User profile section */} | |
| <Box | |
| style={{ | |
| padding: isOpen ? '12px 12px 20px' : '12px 8px 20px', | |
| position: 'relative', | |
| zIndex: 1, | |
| }} | |
| > | |
| {isOpen ? ( | |
| <Box className="sidebar-user"> | |
| <Avatar | |
| src={null} | |
| alt="User" | |
| radius="xl" | |
| size={38} | |
| color="blue" | |
| style={{ | |
| border: '2px solid rgba(255, 255, 255, 0.50)', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.10)', | |
| }} | |
| > | |
| AS | |
| </Avatar> | |
| <Box className="sidebar-user-info"> | |
| <Text className="sidebar-user-name">Ana Souza</Text> | |
| <Text className="sidebar-user-email">ana.souza@exemplo.com</Text> | |
| </Box> | |
| <Tooltip label="Sair" position="top" withArrow> | |
| <UnstyledButton className="sidebar-logout" aria-label="Sair"> | |
| <LogOut size={18} /> | |
| </UnstyledButton> | |
| </Tooltip> | |
| </Box> | |
| ) : ( | |
| <Tooltip label="Ana Souza" position="right" withArrow offset={12}> | |
| <Box style={{ display: 'flex', justifyContent: 'center' }}> | |
| <Avatar | |
| src={null} | |
| alt="User" | |
| radius="xl" | |
| size={40} | |
| color="blue" | |
| style={{ | |
| border: '2px solid rgba(255, 255, 255, 0.50)', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.10)', | |
| cursor: 'pointer', | |
| }} | |
| > | |
| AS | |
| </Avatar> | |
| </Box> | |
| </Tooltip> | |
| )} | |
| </Box> | |
| </motion.div> | |
| ); | |
| } | |
| // ========================= | |
| // App | |
| // ========================= | |
| export default function App() { | |
| const showDesktopColumns = useMediaQuery('(min-width: 48em)') ?? false; | |
| const store = usePeopleStore(INITIAL_PEOPLE); | |
| const [searchField, setSearchField] = useState<SearchField>('name'); | |
| const [query, setQuery] = useState(''); | |
| const [sidebarOpen, setSidebarOpen] = useState(true); | |
| const [activeNavItem, setActiveNavItem] = useState('pessoas'); | |
| const filteredPeople = useMemo(() => { | |
| const isStatus = searchField === 'status'; | |
| const q = isStatus ? query.trim() : normalizeText(query); | |
| if (!q) return store.people; | |
| if (searchField === 'all') { | |
| return store.people.filter((p) => { | |
| const hay = [p.name, p.email, p.role, p.status, p.lastActive] | |
| .map((x) => normalizeText(String(x ?? ''))) | |
| .join(' '); | |
| return hay.includes(normalizeText(q)); | |
| }); | |
| } | |
| if (isStatus) { | |
| return store.people.filter((p) => p.status === (q as Status)); | |
| } | |
| return store.people.filter((p) => { | |
| const value = String((p as any)[searchField] ?? ''); | |
| return normalizeText(value).includes(q); | |
| }); | |
| }, [store.people, query, searchField]); | |
| const [page, setPage] = useState(1); | |
| const paging = useMemo(() => paginate(filteredPeople, page, PAGE_SIZE), [filteredPeople, page]); | |
| useEffect(() => { | |
| if (page !== paging.safePage) setPage(paging.safePage); | |
| }, [page, paging.safePage]); | |
| useEffect(() => { | |
| setPage(1); | |
| }, [query, searchField]); | |
| useEffect(() => { | |
| setQuery(''); | |
| }, [searchField]); | |
| const pager = usePagination({ | |
| total: paging.pageCount, | |
| page: paging.safePage, | |
| onChange: setPage, | |
| siblings: 1, | |
| boundaries: 1, | |
| }); | |
| const [personOpened, personHandlers] = useDisclosure(false); | |
| const [deleteOpened, deleteHandlers] = useDisclosure(false); | |
| const [personMode, setPersonMode] = useState<PersonModalMode>('add'); | |
| const [editingEmail, setEditingEmail] = useState<string | null>(null); | |
| const [personInitial, setPersonInitial] = useState<Person>(emptyPerson()); | |
| const [deletingEmail, setDeletingEmail] = useState<string | null>(null); | |
| const [personErrors, setPersonErrors] = useState<PersonErrors>({}); | |
| const globalCss = useMemo( | |
| () => | |
| FLOATING_LABEL_CSS + | |
| SEARCH_INPUT_CSS + | |
| BLOB_BG_CSS + | |
| MODAL_BTN_CSS + | |
| ACTION_BTN_CSS + | |
| ADD_BTN_CSS + | |
| SIDEBAR_CSS, | |
| [] | |
| ); | |
| function clearError(key: keyof PersonErrors) { | |
| setPersonErrors((prev) => { | |
| if (!(key in prev)) return prev; | |
| const next = { ...prev }; | |
| delete next[key]; | |
| return next; | |
| }); | |
| } | |
| function openAdd() { | |
| setPersonErrors({}); | |
| setPersonMode('add'); | |
| setEditingEmail(null); | |
| setPersonInitial(emptyPerson()); | |
| personHandlers.open(); | |
| } | |
| function openEdit(p: Person) { | |
| setPersonErrors({}); | |
| setPersonMode('edit'); | |
| setEditingEmail(p.email); | |
| setPersonInitial({ ...p }); | |
| personHandlers.open(); | |
| } | |
| function submitPerson(person: Person) { | |
| const required = validateRequiredFields(person); | |
| if (hasErrors(required)) { | |
| setPersonErrors(required); | |
| return; | |
| } | |
| const ignore = personMode === 'edit' ? editingEmail : null; | |
| if (store.emailExists(person.email, ignore)) { | |
| setPersonErrors({ email: 'Esse email já existe na tabela.' }); | |
| return; | |
| } | |
| const normalized: Person = { | |
| ...person, | |
| lastActive: person.lastActive?.trim() ? person.lastActive : 'Agora', | |
| }; | |
| if (personMode === 'add') { | |
| store.add(normalized); | |
| setPage(1); | |
| } else if (editingEmail) { | |
| store.updateByEmail(editingEmail, normalized); | |
| } | |
| personHandlers.close(); | |
| } | |
| function openDelete(p: Person) { | |
| setDeletingEmail(p.email); | |
| deleteHandlers.open(); | |
| } | |
| function confirmDelete() { | |
| if (!deletingEmail) return; | |
| store.removeByEmail(deletingEmail); | |
| setDeletingEmail(null); | |
| deleteHandlers.close(); | |
| } | |
| return ( | |
| <MantineProvider theme={theme} defaultColorScheme="light"> | |
| <BlobBackground /> | |
| <style>{globalCss}</style> | |
| <PersonModal | |
| opened={personOpened} | |
| mode={personMode} | |
| initial={personInitial} | |
| errors={personErrors} | |
| onClose={() => { | |
| setPersonErrors({}); | |
| personHandlers.close(); | |
| }} | |
| onSubmit={submitPerson} | |
| onClearError={clearError} | |
| /> | |
| <ConfirmModal | |
| opened={deleteOpened} | |
| title="Remover pessoa" | |
| description="Tem certeza que deseja remover?" | |
| confirmLabel="Remover" | |
| onClose={() => { | |
| setDeletingEmail(null); | |
| deleteHandlers.close(); | |
| }} | |
| onConfirm={confirmDelete} | |
| /> | |
| <Box mih="100vh" style={{ display: 'flex', position: 'relative', zIndex: 1 }}> | |
| <Sidebar | |
| isOpen={sidebarOpen} | |
| activeItem={activeNavItem} | |
| onToggle={() => setSidebarOpen((s) => !s)} | |
| onNavigate={setActiveNavItem} | |
| /> | |
| <Box | |
| style={{ | |
| flex: 1, | |
| display: 'grid', | |
| placeItems: 'center', | |
| padding: '56px 24px', | |
| }} | |
| > | |
| <Paper w="100%" maw={980} p={{ base: 16, sm: 24 }} radius={22} style={{ position: 'relative', overflow: 'hidden', ...GLASS.panel }}> | |
| <Box style={{ pointerEvents: 'none', position: 'absolute', inset: 0, borderRadius: 22, ...GLASS.panelSheenA }} /> | |
| <Box style={{ pointerEvents: 'none', position: 'absolute', inset: 0, borderRadius: 22, ...GLASS.panelSheenB }} /> | |
| <Box style={{ position: 'relative', zIndex: 1 }}> | |
| <Group justify="space-between" align="center" gap="md" mb="md" wrap="wrap"> | |
| <Stack gap={4}> | |
| <Text fw={700} fz="lg"> | |
| Pessoas | |
| </Text> | |
| <Text fz="sm" c="dimmed"> | |
| Tabela mockada | |
| </Text> | |
| </Stack> | |
| <GlowButton onClick={openAdd}>+ Adicionar</GlowButton> | |
| </Group> | |
| <PeopleTable items={paging.slice} showDesktopColumns={showDesktopColumns} onEdit={openEdit} onDelete={openDelete} /> | |
| </Box> | |
| </Paper> | |
| </Box> | |
| </Box> | |
| </MantineProvider> | |
| ); | |
| } | |
| if ((globalThis as any).__RUN_TESTS__ === true) { | |
| console.assert(clamp(0, 1, 3) === 1, 'clamp lower bound'); | |
| console.assert(clamp(2, 1, 3) === 2, 'clamp inside'); | |
| console.assert(clamp(5, 1, 3) === 3, 'clamp upper bound'); | |
| const p = paginate([1, 2, 3, 4, 5], 2, 2); | |
| console.assert(p.safePage === 2, 'paginate safePage'); | |
| console.assert(p.pageCount === 3, 'paginate pageCount'); | |
| console.assert(p.slice.join(',') === '3,4', 'paginate slice'); | |
| const errs = validateRequiredFields({ ...emptyPerson(), email: 'x@y.com' }); | |
| console.assert(Boolean(errs.name) && Boolean(errs.role), 'required field errors'); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment