Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ dist-ssr
# Local documentation drafts
docs/
doc/

# Test results
test-results/
playwright-report/
blob-report/
playwright/.cache/
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@
<meta name="description" content="Gerenciador de colaboradores Flugo - Performance e Resiliência Offline" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="alternate icon" type="image/png" href="/pwa-192x192.png" />
<title>Flugo Staff Manager</title>
</head>
<body>
<div id="root"></div>
<script>
// Single Page Apps for GitHub Pages
// https://github.com/rafgraph/spa-github-pages
(function(l) {
if (l.search[1] === '/' ) {
var decoded = l.search.slice(1).split('&').map(function(s) {
return s.replace(/~and~/g, '&')
}).join('?');
window.history.replaceState(null, null,
l.pathname.slice(0, -1) + decoded + l.hash
);
}
}(window.location))
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120000,
},
}),
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon.ico
Binary file not shown.
Binary file added public/maskable-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import MenuIcon from '@mui/icons-material/Menu'
import { Sidebar } from '@/components/sidebar'
import { Header } from '@/components/header'
import { Breadcrumb } from '@/components/breadcrumb'
import { useSyncPending } from '@/features/staff/hooks'

function App() {
const [mobileOpen, setMobileOpen] = useState(false)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))

// Ativa a sincronização em segundo plano
useSyncPending()

const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
Expand Down
1 change: 1 addition & 0 deletions src/components/staff-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function StaffForm() {
{/* Barra de Progresso Superior */}
<Box>
<Stack direction="row" alignItems="center" gap={2} mb={1}>

<LinearProgress
variant="determinate"
value={currentProgress || (activeStep === 1 ? 50 : 0)}
Expand Down
54 changes: 18 additions & 36 deletions src/components/staff-list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import {
Avatar,
Box,
Expand All @@ -14,14 +14,12 @@ import {
TablePagination,
TableRow,
TableSortLabel,
Tooltip,
Typography,
Skeleton,
useMediaQuery,
useTheme,
type TableCellProps,
} from '@mui/material'
import SyncIcon from '@mui/icons-material/Sync'
import PersonAddAlt1Icon from '@mui/icons-material/PersonAddAlt1'
import { visuallyHidden } from '@mui/utils'
import { Link } from 'react-router-dom'
Expand All @@ -37,20 +35,17 @@ const columns: { id: keyof Staff; label: string; align?: TableCellProps['align']
{ id: 'status', label: 'Status', align: 'center' },
]


export function StaffList() {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const { data: staffs, isLoading, isError } = useStaffs()
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(10)
const { order, orderBy, createSortHandler } = useSortTable('name', setPage)
const { pendingCount, sync } = useSyncPending()

useEffect(() => {
if (pendingCount > 0) {
sync().catch(console.error)
}
}, [pendingCount, sync])
const { order, orderBy, createSortHandler } = useSortTable('createdAt', setPage, 'desc')

// Sincronização automática em background
useSyncPending()
Comment on lines +45 to +48

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useSyncPending() está sendo chamado aqui e também em src/App.tsx (o App envolve as rotas), o que cria múltiplas instâncias do sync rodando em paralelo quando a lista está montada. Isso pode gerar escrita duplicada/custo extra e corrida no removePendingByEmail. Centralize o sync em um único ponto (ex.: só no App) ou garanta singleton (ex.: provider/flag global).

Copilot uses AI. Check for mistakes.

const sorted = (staffs ?? []).slice().sort(getComparator(order, orderBy))
const paginated = sorted.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
Expand Down Expand Up @@ -225,31 +220,18 @@ export function StaffList() {
{row.department}
</TableCell>
<TableCell align="center">
{row._pendingSync ? (
<Tooltip title="Salvo localmente, aguardando conexão">
<Chip
icon={<SyncIcon sx={{ fontSize: 14 }} />}
label="Sincronizando"
color="warning"
size="small"
variant="outlined"
sx={{ fontWeight: 600, borderStyle: 'dashed' }}
/>
</Tooltip>
) : (
<Chip
label={row.status === 'ACTIVE' ? 'Ativo' : 'Inativo'}
color={row.status === 'ACTIVE' ? 'success' : 'default'}
size="small"
variant="filled"
sx={{
fontWeight: 700,
fontSize: 11,
bgcolor: row.status === 'ACTIVE' ? 'success.light' : 'grey.100',
color: row.status === 'ACTIVE' ? 'success.dark' : 'grey.600'
}}
/>
)}
<Chip
label={row.status === 'ACTIVE' ? 'Ativo' : 'Inativo'}
color={row.status === 'ACTIVE' ? 'success' : 'default'}
size="small"
variant="filled"
sx={{
fontWeight: 700,
fontSize: 11,
bgcolor: row.status === 'ACTIVE' ? 'success.light' : 'grey.100',
color: row.status === 'ACTIVE' ? 'success.dark' : 'grey.600'
}}
/>
</TableCell>
</TableRow>
))}
Expand Down
45 changes: 41 additions & 4 deletions src/features/staff/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,48 @@ describe('Staff Hooks', () => {
})

describe('useSyncPending', () => {
it('deve contar pendentes corretamente', () => {
vi.mocked(localStorageService.getPendingStaffs).mockReturnValue([{}, {}] as any)
const { wrapper } = createWrapper()
it('deve contar pendentes corretamente', async () => {
const mockStaffs = [
{ id: '1', name: 'Sincronizado', _pendingSync: false },
{ id: '2', name: 'Pendente 1', _pendingSync: true },
{ id: '3', name: 'Pendente 2', _pendingSync: true },
]
vi.mocked(staffsService.listStaffs).mockResolvedValue(mockStaffs as any)

const { wrapper, queryClient } = createWrapper()
queryClient.setQueryData(['staffs'], mockStaffs)

const { result } = renderHook(() => useSyncPending(), { wrapper })
expect(result.current.pendingCount).toBe(2)

await waitFor(() => expect(result.current.pendingCount).toBe(2))
})

it('deve evitar sincronizações simultâneas', async () => {
const mockStaffs = [{ id: '1', name: 'Pendente', _pendingSync: true, email: 'p@p.com' }]
vi.mocked(staffsService.listStaffs).mockResolvedValue(mockStaffs as any)

// Simula uma sincronização lenta
let callCount = 0
vi.mocked(staffsService.pushStaffToFirebase).mockImplementation(async () => {
callCount++
await new Promise(resolve => setTimeout(resolve, 100))
return true
})

const { wrapper, queryClient } = createWrapper()
queryClient.setQueryData(['staffs'], mockStaffs)

const { result } = renderHook(() => useSyncPending(), { wrapper })

// Dispara múltiplas sincronizações ao mesmo tempo
await act(async () => {
result.current.sync()
result.current.sync()
result.current.sync()
})

// Mesmo disparando 3 vezes, deve ter chamado o serviço apenas uma vez por causa da trava
expect(callCount).toBe(1)
})
})
})
83 changes: 51 additions & 32 deletions src/features/staff/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createStaff, listStaffs, pushStaffToFirebase, updateStaff, deleteStaff } from '@/services/staffs'
import { getPendingStaffs } from '@/services/local-storage'
import type { StaffSchema } from './validation'
import type { Staff } from './types'
import { useEffect, useRef } from 'react'
import { useConnectivity } from '@/hooks/use-connectivity'

export function useStaffs() {
return useQuery({
Expand All @@ -11,43 +12,35 @@ export function useStaffs() {
})
}

type CreateStaffReturn = Awaited<ReturnType<typeof createStaff>>

export function useCreateStaff() {
const queryClient = useQueryClient()

return useMutation({
return useMutation<
CreateStaffReturn,
Error,
StaffSchema,
{ previousStaffs: Staff[] | undefined }
>({
mutationFn: (data: StaffSchema) => createStaff(data),
onMutate: async (newStaff) => {
await queryClient.cancelQueries({ queryKey: ['staffs'] })
const previousStaffs = queryClient.getQueryData<Staff[]>(['staffs'])

queryClient.setQueryData(['staffs'], (old: Staff[] | undefined) => {
const optimisticEntry: Staff = {
...newStaff,
id: `temp-${Date.now()}`,
_localId: `temp-${Date.now()}`,
_pendingSync: true,
createdAt: Date.now()
}
return old ? [optimisticEntry, ...old] : [optimisticEntry]
queryClient.setQueryData(['staffs'], (old: Staff[] = []) => {
const optimisticEntry: Staff = { ...newStaff, id: `temp-${Date.now()}`, _localId: `temp-${Date.now()}`, _pendingSync: true, createdAt: Date.now() }
return [optimisticEntry, ...old]
})

return { previousStaffs }
},
onSuccess: (result, newStaff) => {
if (result.synced) {
// Online: Firebase é a fonte da verdade, busca os dados reais
queryClient.invalidateQueries({ queryKey: ['staffs'] })
} else {
// Offline: substitui a entrada temporária pelo registro real do localStorage
// sem disparar um refetch (Firebase está indisponível)
const pendingEntry = getPendingStaffs().find(s => s.email === newStaff.email)
if (pendingEntry) {
queryClient.setQueryData(['staffs'], (old: Staff[] | undefined) => {
const withoutTemp = (old || []).filter(s => !s.id.startsWith('temp-'))
return [pendingEntry, ...withoutTemp]
})
}
}
onSuccess: (result) => {
queryClient.setQueryData(['staffs'], (old: Staff[] = []) => {
const withoutTemp = old.filter(s => !s.id.startsWith('temp-'))
const final = [result.staff, ...withoutTemp.filter(s => s.id !== result.staff.id)]
return final
})
},
onError: (_err, _newStaff, context) => {
if (context?.previousStaffs) {
Expand Down Expand Up @@ -79,22 +72,48 @@ export function useDeleteStaff() {

export function useSyncPending() {
const queryClient = useQueryClient()
const pending = getPendingStaffs()
const pendingCount = pending.length
const isOnline = useConnectivity()
const { data: staffs } = useStaffs()
const pendingCount = staffs?.filter(s => s._pendingSync).length ?? 0
const syncingRef = useRef(false)

const sync = async () => {
if (pendingCount === 0) return
if (syncingRef.current) return
const pending = staffs?.filter(s => s._pendingSync)
if (!pending || pending.length === 0) return

syncingRef.current = true
let anySynced = false
let hasError = false

for (const staff of pending) {
const ok = await pushStaffToFirebase(staff)
if (ok) anySynced = true
try {
for (const staff of pending) {
const ok = await pushStaffToFirebase(staff)
if (ok) {
anySynced = true
} else {
hasError = true
}
}
} catch (err) {
if (import.meta.env.DEV) console.error('Erro crítico na sincronização:', err)
hasError = true
} finally {
syncingRef.current = false
}

if (anySynced) {
queryClient.invalidateQueries({ queryKey: ['staffs'] })
}

return { anySynced, hasError }
}

useEffect(() => {
if (isOnline && pendingCount > 0) {
sync()
}
}, [isOnline, pendingCount])

return { pendingCount, sync }
}
19 changes: 4 additions & 15 deletions src/features/staff/use-staff-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,13 @@ export function useStaffForm() {

const onSubmit = async (data: StaffSchema) => {
try {
const result = await createStaff(data)
await createStaff(data)

submittedRef.current = true
localStorage.removeItem(draftKey)

if (result.synced) {
setToast({ message: 'Colaborador cadastrado com sucesso!', severity: 'success' })
} else {
setToast({
message: `Salvo no dispositivo. Será enviado quando houver internet.`,
severity: 'success'
})
}

setTimeout(() => navigate('/staffs'), 1500)
setToast({ message: 'Colaborador cadastrado com sucesso!', severity: 'success' })
setTimeout(() => navigate('/staffs'), 2000)
} catch (err: unknown) {
setToast({
message: err instanceof Error ? err.message : 'Não foi possível salvar os dados.',
Expand All @@ -73,24 +65,21 @@ export function useStaffForm() {
}

const handleNext = async () => {
// Validação Granular por Sub-Schema de Step
const currentStepSchema = stepSchemas[activeStep]
const currentStepValues = form.getValues()

// Valida apenas os campos do passo atual contra o sub-schema
const stepValidation = await currentStepSchema.safeParseAsync(currentStepValues)

if (!stepValidation.success) {
// Mapeia erros do sub-schema para o form global do React Hook Form
stepValidation.error.issues.forEach((issue) => {
form.setError(issue.path[0] as any, {
message: issue.message
})
})
setToast({ message: 'Verifique os campos marcados em vermelho.', severity: 'error' })
return false
}

// UX Preventiva: Validação de E-mail Único no Step 0
if (activeStep === 0) {
const email = form.getValues('email').trim().toLowerCase()
const isDuplicate = staffs?.some(s => s.email.trim().toLowerCase() === email)
Expand Down
Loading
Loading