Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created December 17, 2025 01:40
Show Gist options
  • Select an option

  • Save celsowm/0343324bb6753d58d3880c2b58155f1c to your computer and use it in GitHub Desktop.

Select an option

Save celsowm/0343324bb6753d58d3880c2b58155f1c to your computer and use it in GitHub Desktop.
pgedigital_mantine.ts
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