Skip to content

Commit 4013824

Browse files
adiciona suporte a PWA com cache offline
1 parent 89ef266 commit 4013824

10 files changed

Lines changed: 209 additions & 165 deletions

File tree

index.html

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,12 @@
22
<html lang="pt-BR">
33
<head>
44
<meta charset="UTF-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>Flugo Employees</title>
7-
<script>
8-
// Restore URL after 404.html redirect (GitHub Pages SPA routing)
9-
;(function () {
10-
var redirect = sessionStorage.redirect
11-
delete sessionStorage.redirect
12-
if (redirect && redirect !== location.href) {
13-
history.replaceState(null, null, redirect)
14-
}
15-
// Handle ?p= query param from 404.html
16-
var l = window.location
17-
if (l.search[1] === 'p') {
18-
var decoded = l.search
19-
.slice(1)
20-
.split('&')
21-
.reduce(function (memo, item) {
22-
var parts = item.split('=')
23-
memo[parts[0]] = parts.slice(1).join('=').replace(/~and~/g, '&')
24-
return memo
25-
}, {})
26-
if (decoded.p !== undefined) {
27-
history.replaceState(
28-
null,
29-
null,
30-
l.pathname.slice(0, -1) +
31-
(decoded.p ? '/' + decoded.p : '') +
32-
(decoded.q ? '?' + decoded.q : '') +
33-
l.hash
34-
)
35-
}
36-
}
37-
})()
38-
</script>
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
6+
<meta name="theme-color" content="#1976d2" />
7+
<meta name="description" content="Gerenciador de colaboradores Flugo - Performance e Resiliência Offline" />
8+
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
9+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
10+
<title>Flugo Staff Manager</title>
3911
</head>
4012
<body>
4113
<div id="root"></div>

public/pwa-192x192.png

616 Bytes
Loading

public/pwa-512x512.png

2.13 KB
Loading

src/features/staff/hooks.test.tsx

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { renderHook, waitFor } from '@testing-library/react'
2+
import { renderHook, waitFor, act } from '@testing-library/react'
33
import { useStaffs, useCreateStaff, useSyncPending } from './hooks'
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import * as staffsService from '@/services/staffs'
@@ -11,6 +11,8 @@ vi.mock('@/services/staffs', () => ({
1111
listStaffs: vi.fn(),
1212
createStaff: vi.fn(),
1313
pushStaffToFirebase: vi.fn(),
14+
updateStaff: vi.fn(),
15+
deleteStaff: vi.fn(),
1416
}))
1517

1618
vi.mock('@/services/local-storage', () => ({
@@ -20,12 +22,15 @@ vi.mock('@/services/local-storage', () => ({
2022
const createWrapper = () => {
2123
const queryClient = new QueryClient({
2224
defaultOptions: {
23-
queries: { retry: false },
25+
queries: { retry: false, gcTime: Infinity, staleTime: Infinity },
2426
},
2527
})
26-
return ({ children }: { children: React.ReactNode }) => (
27-
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
28-
)
28+
return {
29+
queryClient,
30+
wrapper: ({ children }: { children: React.ReactNode }) => (
31+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
32+
)
33+
}
2934
}
3035

3136
describe('Staff Hooks', () => {
@@ -34,50 +39,66 @@ describe('Staff Hooks', () => {
3439
})
3540

3641
describe('useStaffs', () => {
37-
it('deve chamar listStaffs e retornar dados', async () => {
42+
it('deve retornar dados do serviço', async () => {
3843
const mockData = [{ id: '1', name: 'Test' }]
3944
vi.mocked(staffsService.listStaffs).mockResolvedValue(mockData as any)
4045

41-
const { result } = renderHook(() => useStaffs(), { wrapper: createWrapper() })
46+
const { wrapper } = createWrapper()
47+
const { result } = renderHook(() => useStaffs(), { wrapper })
4248

4349
await waitFor(() => expect(result.current.isSuccess).toBe(true))
4450
expect(result.current.data).toEqual(mockData)
45-
expect(staffsService.listStaffs).toHaveBeenCalled()
4651
})
4752
})
4853

49-
describe('useCreateStaff', () => {
50-
it('deve chamar createStaff ao mutar', async () => {
51-
vi.mocked(staffsService.createStaff).mockResolvedValue({ synced: true })
54+
describe('useCreateStaff (Optimistic Updates)', () => {
55+
it('deve injetar colaborador no cache instantaneamente', async () => {
56+
// Mock com delay para capturar o estado otimista
57+
vi.mocked(staffsService.createStaff).mockImplementation(() =>
58+
new Promise(resolve => setTimeout(() => resolve({ synced: true }), 100))
59+
)
5260

53-
const { result } = renderHook(() => useCreateStaff(), { wrapper: createWrapper() })
61+
const { queryClient, wrapper } = createWrapper()
62+
queryClient.setQueryData(['staffs'], [])
63+
64+
const { result } = renderHook(() => useCreateStaff(), { wrapper })
65+
const newStaff = { name: 'Optimistic User', email: 'opt@test.com', department: 'TI', status: 'ACTIVE' }
5466

55-
result.current.mutate({ name: 'New', email: 'a@a.com' } as any)
67+
await act(async () => {
68+
result.current.mutate(newStaff as any)
69+
})
5670

57-
await waitFor(() => expect(result.current.isSuccess).toBe(true))
58-
expect(staffsService.createStaff).toHaveBeenCalled()
71+
const cached = queryClient.getQueryData<any[]>(['staffs'])
72+
expect(cached).toBeDefined()
73+
expect(cached?.length).toBe(1)
74+
expect(cached![0]).toMatchObject({ name: 'Optimistic User', _pendingSync: true })
5975
})
60-
})
6176

62-
describe('useSyncPending', () => {
63-
it('deve retornar a contagem correta de pendentes', () => {
64-
vi.mocked(localStorageService.getPendingStaffs).mockReturnValue([{}, {}] as any)
65-
66-
const { result } = renderHook(() => useSyncPending(), { wrapper: createWrapper() })
77+
it('deve restaurar cache em caso de erro', async () => {
78+
vi.mocked(staffsService.createStaff).mockRejectedValue(new Error('Fail'))
6779

68-
expect(result.current.pendingCount).toBe(2)
69-
})
80+
const { queryClient, wrapper } = createWrapper()
81+
const previous = [{ id: 'old', name: 'Old' }]
82+
queryClient.setQueryData(['staffs'], previous)
7083

71-
it('deve tentar sincronizar cada item pendente', async () => {
72-
const mockPending = [{ email: '1@a.com' }, { email: '2@a.com' }]
73-
vi.mocked(localStorageService.getPendingStaffs).mockReturnValue(mockPending as any)
74-
vi.mocked(staffsService.pushStaffToFirebase).mockResolvedValue(true)
75-
76-
const { result } = renderHook(() => useSyncPending(), { wrapper: createWrapper() })
84+
const { result } = renderHook(() => useCreateStaff(), { wrapper })
7785

78-
await result.current.sync()
86+
await act(async () => {
87+
try {
88+
await result.current.mutateAsync({ name: 'Error' } as any)
89+
} catch (e) {}
90+
})
91+
92+
expect(queryClient.getQueryData(['staffs'])).toEqual(previous)
93+
})
94+
})
7995

80-
expect(staffsService.pushStaffToFirebase).toHaveBeenCalledTimes(2)
96+
describe('useSyncPending', () => {
97+
it('deve contar pendentes corretamente', () => {
98+
vi.mocked(localStorageService.getPendingStaffs).mockReturnValue([{}, {}] as any)
99+
const { wrapper } = createWrapper()
100+
const { result } = renderHook(() => useSyncPending(), { wrapper })
101+
expect(result.current.pendingCount).toBe(2)
81102
})
82103
})
83104
})

src/features/staff/hooks.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { useMutation, useQuery } from '@tanstack/react-query'
2-
import { queryClient } from '@/libs/tanstack-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
32
import { createStaff, listStaffs, pushStaffToFirebase, updateStaff, deleteStaff } from '@/services/staffs'
43
import { getPendingStaffs } from '@/services/local-storage'
54
import type { StaffSchema } from './validation'
@@ -13,33 +12,50 @@ export function useStaffs() {
1312
}
1413

1514
export function useCreateStaff() {
15+
const queryClient = useQueryClient()
16+
1617
return useMutation({
1718
mutationFn: (data: StaffSchema) => createStaff(data),
19+
20+
// 🛡️ Defesa de Arquiteto: Optimistic Update para UX instantânea
1821
onMutate: async (newStaff) => {
22+
// Cancela queries em andamento para não sobrescrever o cache otimista
1923
await queryClient.cancelQueries({ queryKey: ['staffs'] })
24+
25+
// Snapshot do cache atual para rollback em caso de erro
2026
const previousStaffs = queryClient.getQueryData<Staff[]>(['staffs'])
2127

28+
// Atualiza o cache instantaneamente
2229
queryClient.setQueryData(['staffs'], (old: Staff[] | undefined) => {
2330
const optimisticEntry: Staff = {
24-
id: `temp-${Date.now()}`,
2531
...newStaff,
32+
id: `temp-${Date.now()}`,
33+
_localId: `temp-${Date.now()}`,
2634
_pendingSync: true,
35+
createdAt: Date.now()
2736
}
2837
return old ? [optimisticEntry, ...old] : [optimisticEntry]
2938
})
3039

3140
return { previousStaffs }
3241
},
42+
43+
// Em caso de erro real (ex: timeout longo), restaura o cache anterior
3344
onError: (_err, _newStaff, context) => {
34-
queryClient.setQueryData(['staffs'], context?.previousStaffs)
45+
if (context?.previousStaffs) {
46+
queryClient.setQueryData(['staffs'], context.previousStaffs)
47+
}
3548
},
49+
50+
// Sempre invalida após finalizar para sincronizar com a "verdade" do servidor
3651
onSettled: () => {
3752
queryClient.invalidateQueries({ queryKey: ['staffs'] })
3853
},
3954
})
4055
}
4156

4257
export function useUpdateStaff() {
58+
const queryClient = useQueryClient()
4359
return useMutation({
4460
mutationFn: ({ id, data }: { id: string; data: StaffSchema }) => updateStaff(id, data),
4561
onSuccess: () => {
@@ -49,6 +65,7 @@ export function useUpdateStaff() {
4965
}
5066

5167
export function useDeleteStaff() {
68+
const queryClient = useQueryClient()
5269
return useMutation({
5370
mutationFn: (id: string) => deleteStaff(id),
5471
onSuccess: () => {
@@ -58,6 +75,7 @@ export function useDeleteStaff() {
5875
}
5976

6077
export function useSyncPending() {
78+
const queryClient = useQueryClient()
6179
const pending = getPendingStaffs()
6280
const pendingCount = pending.length
6381

src/features/staff/use-staff-form.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@ import { useState, useEffect, useRef, useMemo } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { useForm } from 'react-hook-form'
44
import { zodResolver } from '@hookform/resolvers/zod'
5-
import { staffSchema, type StaffSchema } from '@/features/staff/validation'
5+
import { staffSchema, stepSchemas, type StaffSchema } from '@/features/staff/validation'
66
import { useCreateStaff, useStaffs } from '@/features/staff/hooks'
77

88
const STEPS = ['Infos Básicas', 'Infos Profissionais']
9-
const STEP_FIELDS: Array<Array<keyof StaffSchema>> = [
10-
['name', 'email', 'status'],
11-
['department'],
12-
]
139

1410
export function useStaffForm() {
1511
const navigate = useNavigate()
@@ -77,8 +73,24 @@ export function useStaffForm() {
7773
}
7874

7975
const handleNext = async () => {
80-
const fields = STEP_FIELDS[activeStep]
76+
// Validação Granular por Sub-Schema de Step
77+
const currentStepSchema = stepSchemas[activeStep]
78+
const currentStepValues = form.getValues()
8179

80+
// Valida apenas os campos do passo atual contra o sub-schema
81+
const stepValidation = await currentStepSchema.safeParseAsync(currentStepValues)
82+
83+
if (!stepValidation.success) {
84+
// Mapeia erros do sub-schema para o form global do React Hook Form
85+
stepValidation.error.issues.forEach((issue) => {
86+
form.setError(issue.path[0] as any, {
87+
message: issue.message
88+
})
89+
})
90+
return false
91+
}
92+
93+
// UX Preventiva: Validação de E-mail Único no Step 0
8294
if (activeStep === 0) {
8395
const email = form.getValues('email').trim().toLowerCase()
8496
const isDuplicate = staffs?.some(s => s.email.trim().toLowerCase() === email)
@@ -92,9 +104,6 @@ export function useStaffForm() {
92104
}
93105
}
94106

95-
const isValid = await form.trigger(fields)
96-
if (!isValid) return false
97-
98107
if (activeStep === STEPS.length - 1) {
99108
await form.handleSubmit(onSubmit)()
100109
} else {

0 commit comments

Comments
 (0)