Generated by Spark: Salesforce style login screen, Introduce redux and apply to all pages/custom hooks where needed

This commit is contained in:
2026-01-23 09:31:15 +00:00
committed by GitHub
parent ae416c803e
commit 977c3dcbd6
22 changed files with 1189 additions and 18 deletions

11
PRD.md
View File

@@ -8,14 +8,21 @@ A cloud-based workforce back-office platform that centralizes timesheet manageme
3. **Transparent** - Clear visibility into workflow status, approval chains, and data lineage with comprehensive audit trails and real-time updates
**Complexity Level**: Complex Application (advanced functionality, likely with multiple views)
This is a multi-module enterprise platform requiring navigation between distinct functional areas (timesheets, billing, payroll, compliance), role-based access control, real-time dashboards, complex form workflows, and extensive data visualization capabilities.
This is a multi-module enterprise platform requiring navigation between distinct functional areas (timesheets, billing, payroll, compliance), role-based access control, real-time dashboards, complex form workflows, extensive data visualization capabilities, and Redux-powered state management for scalable, predictable application state.
## Essential Features
**Authentication & Login**
- Functionality: Salesforce-style login screen with email/password authentication
- Purpose: Secure access to the platform with professional enterprise-grade authentication
- Trigger: User navigates to application without authenticated session
- Progression: Enter credentials → Validate → Show loading state → Redirect to dashboard
- Success criteria: Smooth authentication flow, password visibility toggle, remember me option, secure session management
**Dashboard Overview**
- Functionality: Displays real-time KPIs, alerts, and quick actions across all modules
- Purpose: Provides at-a-glance operational health and reduces time to critical actions
- Trigger: User login or navigation to home
- Trigger: Successful user login or navigation to home
- Progression: Login → Dashboard loads with widgets → User scans metrics → Clicks widget to drill down → Navigates to relevant module
- Success criteria: All KPIs update in real-time, widgets are interactive, no data older than 5 minutes

300
REDUX_GUIDE.md Normal file
View File

@@ -0,0 +1,300 @@
# Redux Integration Guide
This application uses Redux Toolkit for centralized state management across the entire platform.
## Architecture Overview
The Redux store is configured in `/src/store/store.ts` and provides the following slices:
### State Slices
#### 1. Auth Slice (`/src/store/slices/authSlice.ts`)
Manages authentication state and user information.
**State:**
- `user`: Current authenticated user object
- `isAuthenticated`: Boolean flag for auth status
- `currentEntity`: Selected organizational entity
**Actions:**
- `login(user)`: Authenticates user and stores user data
- `logout()`: Clears user session
- `setCurrentEntity(entity)`: Switches between organizational entities
#### 2. UI Slice (`/src/store/slices/uiSlice.ts`)
Manages global UI state.
**State:**
- `currentView`: Active view/page in the application
- `searchQuery`: Global search query string
- `sidebarCollapsed`: Sidebar visibility state
**Actions:**
- `setCurrentView(view)`: Navigate to different views
- `setSearchQuery(query)`: Update search query
- `toggleSidebar()`: Toggle sidebar collapsed state
#### 3. Timesheets Slice (`/src/store/slices/timesheetsSlice.ts`)
Manages timesheet data.
**State:**
- `timesheets`: Array of timesheet objects
- `loading`: Loading state for async operations
**Actions:**
- `setTimesheets(timesheets)`: Replace all timesheets
- `addTimesheet(timesheet)`: Add new timesheet
- `updateTimesheet(timesheet)`: Update existing timesheet
- `deleteTimesheet(id)`: Remove timesheet by ID
- `setLoading(boolean)`: Set loading state
#### 4. Invoices Slice (`/src/store/slices/invoicesSlice.ts`)
Manages invoice data.
**State:**
- `invoices`: Array of invoice objects
- `loading`: Loading state for async operations
**Actions:**
- `setInvoices(invoices)`: Replace all invoices
- `addInvoice(invoice)`: Add new invoice
- `updateInvoice(invoice)`: Update existing invoice
- `deleteInvoice(id)`: Remove invoice by ID
- `setLoading(boolean)`: Set loading state
#### 5. Payroll Slice (`/src/store/slices/payrollSlice.ts`)
Manages payroll run data.
**State:**
- `payrollRuns`: Array of payroll run objects
- `loading`: Loading state for async operations
**Actions:**
- `setPayrollRuns(runs)`: Replace all payroll runs
- `addPayrollRun(run)`: Add new payroll run
- `updatePayrollRun(run)`: Update existing payroll run
- `setLoading(boolean)`: Set loading state
#### 6. Compliance Slice (`/src/store/slices/complianceSlice.ts`)
Manages compliance document data.
**State:**
- `documents`: Array of compliance document objects
- `loading`: Loading state for async operations
**Actions:**
- `setComplianceDocs(docs)`: Replace all compliance documents
- `addComplianceDoc(doc)`: Add new compliance document
- `updateComplianceDoc(doc)`: Update existing compliance document
- `deleteComplianceDoc(id)`: Remove compliance document by ID
- `setLoading(boolean)`: Set loading state
#### 7. Expenses Slice (`/src/store/slices/expensesSlice.ts`)
Manages expense data.
**State:**
- `expenses`: Array of expense objects
- `loading`: Loading state for async operations
**Actions:**
- `setExpenses(expenses)`: Replace all expenses
- `addExpense(expense)`: Add new expense
- `updateExpense(expense)`: Update existing expense
- `deleteExpense(id)`: Remove expense by ID
- `setLoading(boolean)`: Set loading state
#### 8. Notifications Slice (`/src/store/slices/notificationsSlice.ts`)
Manages system notifications.
**State:**
- `notifications`: Array of notification objects
- `unreadCount`: Number of unread notifications
**Actions:**
- `setNotifications(notifications)`: Replace all notifications
- `addNotification(notification)`: Add new notification
- `markAsRead(id)`: Mark notification as read
- `markAllAsRead()`: Mark all notifications as read
- `deleteNotification(id)`: Remove notification by ID
## Using Redux in Components
### 1. Import the typed hooks
```typescript
import { useAppSelector, useAppDispatch } from '@/store/hooks'
```
### 2. Access state with useAppSelector
```typescript
const user = useAppSelector(state => state.auth.user)
const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated)
const timesheets = useAppSelector(state => state.timesheets.timesheets)
```
### 3. Dispatch actions with useAppDispatch
```typescript
import { login, logout } from '@/store/slices/authSlice'
import { setCurrentView } from '@/store/slices/uiSlice'
const dispatch = useAppDispatch()
// Dispatch actions
dispatch(login({ id: '1', email: 'user@example.com', name: 'User', role: 'Admin' }))
dispatch(setCurrentView('dashboard'))
dispatch(logout())
```
## Custom Hooks with Redux
We've created several custom hooks that wrap Redux logic for cleaner component code:
### useAuth Hook (`/src/hooks/use-auth.ts`)
```typescript
import { useAuth } from '@/hooks/use-auth'
function MyComponent() {
const { user, isAuthenticated, currentEntity, logout } = useAuth()
return (
<div>
{isAuthenticated && <p>Welcome, {user?.name}!</p>}
<button onClick={logout}>Log Out</button>
</div>
)
}
```
### useNavigation Hook (`/src/hooks/use-navigation.ts`)
```typescript
import { useNavigation } from '@/hooks/use-navigation'
function MyComponent() {
const { currentView, navigateTo } = useNavigation()
return (
<button onClick={() => navigateTo('dashboard')}>
Go to Dashboard
</button>
)
}
```
### useReduxNotifications Hook (`/src/hooks/use-redux-notifications.ts`)
```typescript
import { useReduxNotifications } from '@/hooks/use-redux-notifications'
function MyComponent() {
const { notifications, unreadCount, addNotification, markAsRead } = useReduxNotifications()
const handleAction = () => {
addNotification({
type: 'success',
title: 'Success',
message: 'Action completed successfully',
})
}
return (
<div>
<p>Unread: {unreadCount}</p>
{notifications.map(n => (
<div key={n.id} onClick={() => markAsRead(n.id)}>
{n.title}
</div>
))}
</div>
)
}
```
## Best Practices
1. **Use typed hooks**: Always use `useAppSelector` and `useAppDispatch` instead of the untyped versions from `react-redux`
2. **Create custom hooks**: Wrap common Redux patterns in custom hooks for reusability and cleaner components
3. **Keep slices focused**: Each slice should manage a single domain of state
4. **Use Redux DevTools**: The store is configured with Redux DevTools support for debugging
5. **Immutable updates**: Redux Toolkit uses Immer internally, so you can write "mutating" code in reducers that is automatically converted to immutable updates
6. **Async operations**: For async operations, consider using Redux Toolkit's `createAsyncThunk` or handle them in components/custom hooks
## Example: Complete Component with Redux
```typescript
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import { setTimesheets, addTimesheet } from '@/store/slices/timesheetsSlice'
import { addNotification } from '@/store/slices/notificationsSlice'
import { Button } from '@/components/ui/button'
export function TimesheetList() {
const dispatch = useAppDispatch()
const timesheets = useAppSelector(state => state.timesheets.timesheets)
const loading = useAppSelector(state => state.timesheets.loading)
const handleAddTimesheet = () => {
const newTimesheet = {
id: `TS-${Date.now()}`,
workerName: 'New Worker',
status: 'pending',
// ... other fields
}
dispatch(addTimesheet(newTimesheet))
dispatch(addNotification({
id: `notif-${Date.now()}`,
type: 'success',
title: 'Timesheet Added',
message: 'New timesheet created successfully',
timestamp: new Date().toISOString(),
read: false,
}))
}
if (loading) return <div>Loading...</div>
return (
<div>
<Button onClick={handleAddTimesheet}>Add Timesheet</Button>
{timesheets.map(ts => (
<div key={ts.id}>{ts.workerName}</div>
))}
</div>
)
}
```
## Migration from useState to Redux
For existing components using local state, follow this pattern:
**Before (local state):**
```typescript
const [currentView, setCurrentView] = useState('dashboard')
```
**After (Redux):**
```typescript
const currentView = useAppSelector(state => state.ui.currentView)
const dispatch = useAppDispatch()
// To update:
dispatch(setCurrentView('timesheets'))
```
## Future Enhancements
Consider adding these Redux features as the application grows:
- **Redux Persist**: Persist state to localStorage/sessionStorage
- **RTK Query**: For API data fetching and caching
- **createAsyncThunk**: For complex async operations
- **Entity Adapters**: For normalized state management of collections

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WorkForce Pro - Enhanced Back Office Platform</title>
<title>WorkForce Pro - Back Office Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">

94
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.1",
@@ -60,6 +61,7 @@
"react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.54.2",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.1",
"sonner": "^2.0.1",
@@ -3083,6 +3085,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -3557,6 +3585,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -4404,6 +4438,12 @@
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
@@ -7196,6 +7236,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8557,6 +8607,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -8713,6 +8786,27 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",

View File

@@ -44,6 +44,7 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.1",
@@ -64,6 +65,7 @@
"react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.54.2",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.1",
"sonner": "^2.0.1",
@@ -94,4 +96,4 @@
"packages/*"
]
}
}
}

View File

@@ -1,4 +1,3 @@
import { useState } from 'react'
import { useSampleData } from '@/hooks/use-sample-data'
import { useNotifications } from '@/hooks/use-notifications'
import { useAppData } from '@/hooks/use-app-data'
@@ -8,17 +7,23 @@ import { Sidebar } from '@/components/navigation'
import { NotificationCenter } from '@/components/NotificationCenter'
import { ViewRouter } from '@/components/ViewRouter'
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
import LoginScreen from '@/components/LoginScreen'
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import { setCurrentView, setSearchQuery } from '@/store/slices/uiSlice'
import { setCurrentEntity } from '@/store/slices/authSlice'
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo'
function App() {
const dispatch = useAppDispatch()
const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated)
const currentEntity = useAppSelector(state => state.auth.currentEntity)
const currentView = useAppSelector(state => state.ui.currentView)
const searchQuery = useAppSelector(state => state.ui.searchQuery)
useSampleData()
useViewPreload()
const [currentView, setCurrentView] = useState<View>('dashboard')
const [currentEntity, setCurrentEntity] = useState('Main Agency')
const [searchQuery, setSearchQuery] = useState('')
const { notifications, addNotification, markAsRead, markAllAsRead, deleteNotification, unreadCount } = useNotifications()
const {
@@ -47,13 +52,17 @@ function App() {
addNotification
)
if (!isAuthenticated) {
return <LoginScreen />
}
return (
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar
currentView={currentView}
setCurrentView={setCurrentView}
setCurrentView={(view) => dispatch(setCurrentView(view as View))}
currentEntity={currentEntity}
setCurrentEntity={setCurrentEntity}
setCurrentEntity={(entity) => dispatch(setCurrentEntity(entity))}
metrics={metrics}
/>
@@ -78,7 +87,7 @@ function App() {
<ViewRouter
currentView={currentView}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
setSearchQuery={(query) => dispatch(setSearchQuery(query))}
metrics={metrics}
timesheets={timesheets}
invoices={invoices}

View File

@@ -0,0 +1,212 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Buildings, Lock, User, Eye, EyeSlash } from '@phosphor-icons/react'
import { useAppDispatch } from '@/store/hooks'
import { login } from '@/store/slices/authSlice'
import { toast } from 'sonner'
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [rememberMe, setRememberMe] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const dispatch = useAppDispatch()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) {
toast.error('Please enter your email and password')
return
}
setIsLoading(true)
setTimeout(() => {
dispatch(login({
id: '1',
email,
name: email.split('@')[0].split('.').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
role: 'Admin',
avatarUrl: undefined,
}))
toast.success('Welcome back!')
setIsLoading(false)
}, 800)
}
return (
<div className="min-h-screen w-full flex">
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary via-accent to-primary/80 relative overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-96 h-96 bg-white rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-80 h-80 bg-white rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 w-72 h-72 bg-white rounded-full blur-3xl" />
</div>
<div className="relative z-10 flex flex-col justify-center px-16 text-white">
<div className="mb-8">
<Buildings size={64} weight="duotone" />
</div>
<h1 className="text-5xl font-semibold mb-6 leading-tight">
Welcome to<br />WorkForce Pro
</h1>
<p className="text-xl text-white/90 leading-relaxed max-w-md">
Your complete back-office platform for workforce management, billing, and compliance.
</p>
<div className="mt-12 space-y-4">
<div className="flex items-start gap-4">
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 mt-1">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div>
<h3 className="font-medium text-lg mb-1">Streamlined Operations</h3>
<p className="text-white/80 text-sm">Automate timesheets, billing, and payroll in one unified platform</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 mt-1">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div>
<h3 className="font-medium text-lg mb-1">Real-Time Insights</h3>
<p className="text-white/80 text-sm">Monitor KPIs and make data-driven decisions instantly</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 mt-1">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div>
<h3 className="font-medium text-lg mb-1">Enterprise Security</h3>
<p className="text-white/80 text-sm">Bank-level encryption and compliance-ready audit trails</p>
</div>
</div>
</div>
</div>
</div>
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md">
<div className="mb-10">
<div className="flex items-center gap-3 mb-2">
<Buildings size={32} className="text-primary" weight="duotone" />
<span className="text-2xl font-semibold text-foreground">WorkForce Pro</span>
</div>
<h2 className="text-3xl font-semibold text-foreground mb-2">Log in to your account</h2>
<p className="text-muted-foreground">Enter your credentials to access the platform</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email Address
</Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<User size={18} />
</div>
<Input
id="email"
type="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10 h-11"
autoComplete="email"
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
Password
</Label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<Lock size={18} />
</div>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 h-11"
autoComplete="current-password"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeSlash size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
disabled={isLoading}
/>
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
Remember me
</Label>
</div>
<button
type="button"
className="text-sm text-accent hover:text-accent/80 font-medium transition-colors"
disabled={isLoading}
>
Forgot password?
</button>
</div>
<Button
type="submit"
className="w-full h-11 text-base font-medium"
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</form>
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<button className="text-accent hover:text-accent/80 font-medium transition-colors">
Contact Sales
</button>
</p>
</div>
<div className="mt-8 pt-8 border-t border-border">
<p className="text-xs text-center text-muted-foreground">
By logging in, you agree to our{' '}
<button className="text-foreground hover:text-accent transition-colors">
Terms of Service
</button>{' '}
and{' '}
<button className="text-foreground hover:text-accent transition-colors">
Privacy Policy
</button>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import {
ChartBar,
Buildings,
@@ -8,10 +9,12 @@ import {
Question,
PuzzlePiece,
Code,
Database
Database,
SignOut
} from '@phosphor-icons/react'
import { NavItem } from '@/components/nav/NavItem'
import { CoreOperationsNav, ReportsNav, ConfigurationNav, ToolsNav } from '@/components/nav/nav-sections'
import { useAuth } from '@/hooks/use-auth'
import type { View } from '@/App'
import type { DashboardMetrics } from '@/lib/types'
@@ -25,6 +28,7 @@ interface SidebarProps {
export function Sidebar({ currentView, setCurrentView, currentEntity, setCurrentEntity, metrics }: SidebarProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['core']))
const { user, logout } = useAuth()
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => {
@@ -38,6 +42,16 @@ export function Sidebar({ currentView, setCurrentView, currentEntity, setCurrent
})
}
const getUserInitials = () => {
if (!user) return 'U'
return user.name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
return (
<aside className="w-64 border-r border-border bg-card flex flex-col">
<div className="p-6 border-b border-border">
@@ -137,16 +151,25 @@ export function Sidebar({ currentView, setCurrentView, currentEntity, setCurrent
/>
</nav>
<div className="p-4 border-t border-border">
<div className="p-4 border-t border-border space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
AD
{getUserInitials()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Admin User</p>
<p className="text-xs text-muted-foreground truncate">admin@workforce.io</p>
<p className="text-sm font-medium truncate">{user?.name || 'User'}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email || ''}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
onClick={logout}
>
<SignOut size={16} />
Log Out
</Button>
</div>
</aside>
)

20
src/hooks/use-auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import { logout } from '@/store/slices/authSlice'
import { setCurrentView } from '@/store/slices/uiSlice'
export function useAuth() {
const dispatch = useAppDispatch()
const { user, isAuthenticated, currentEntity } = useAppSelector(state => state.auth)
const handleLogout = () => {
dispatch(logout())
dispatch(setCurrentView('dashboard'))
}
return {
user,
isAuthenticated,
currentEntity,
logout: handleLogout,
}
}

View File

@@ -0,0 +1,19 @@
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import { setCurrentView } from '@/store/slices/uiSlice'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo'
export function useNavigation() {
const dispatch = useAppDispatch()
const { currentView, searchQuery } = useAppSelector(state => state.ui)
const navigateTo = (view: View) => {
dispatch(setCurrentView(view))
}
return {
currentView,
searchQuery,
navigateTo,
}
}

View File

@@ -0,0 +1,44 @@
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import {
addNotification as addNotificationAction,
markAsRead,
markAllAsRead,
deleteNotification
} from '@/store/slices/notificationsSlice'
import type { Notification } from '@/store/slices/notificationsSlice'
export function useReduxNotifications() {
const dispatch = useAppDispatch()
const { notifications, unreadCount } = useAppSelector(state => state.notifications)
const addNotification = (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
const newNotification: Notification = {
...notification,
id: `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
read: false,
}
dispatch(addNotificationAction(newNotification))
}
const markNotificationAsRead = (id: string) => {
dispatch(markAsRead(id))
}
const markAllNotificationsAsRead = () => {
dispatch(markAllAsRead())
}
const deleteNotificationById = (id: string) => {
dispatch(deleteNotification(id))
}
return {
notifications,
unreadCount,
addNotification,
markAsRead: markNotificationAsRead,
markAllAsRead: markAllNotificationsAsRead,
deleteNotification: deleteNotificationById,
}
}

View File

@@ -1,9 +1,12 @@
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from "react-error-boundary";
import { Provider } from 'react-redux'
import "@github/spark/spark"
import App from './App.tsx'
import { ErrorFallback } from './ErrorFallback.tsx'
import { store } from './store/store'
import { Toaster } from '@/components/ui/sonner'
import "./main.css"
import "./styles/theme.css"
@@ -11,6 +14,9 @@ import "./index.css"
createRoot(document.getElementById('root')!).render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<App />
<Provider store={store}>
<App />
<Toaster />
</Provider>
</ErrorBoundary>
)

5
src/store/hooks.ts Normal file
View File

@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface User {
id: string
email: string
name: string
role: string
avatarUrl?: string
}
interface AuthState {
user: User | null
isAuthenticated: boolean
currentEntity: string
}
const initialState: AuthState = {
user: null,
isAuthenticated: false,
currentEntity: 'Main Agency',
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
login: (state, action: PayloadAction<User>) => {
state.user = action.payload
state.isAuthenticated = true
},
logout: (state) => {
state.user = null
state.isAuthenticated = false
},
setCurrentEntity: (state, action: PayloadAction<string>) => {
state.currentEntity = action.payload
},
},
})
export const { login, logout, setCurrentEntity } = authSlice.actions
export default authSlice.reducer

View File

@@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface ComplianceDoc {
id: string
workerId: string
workerName: string
documentType: string
status: string
expiryDate: string
uploadedDate: string
verifiedDate?: string
daysUntilExpiry: number
}
interface ComplianceState {
documents: ComplianceDoc[]
loading: boolean
}
const initialState: ComplianceState = {
documents: [],
loading: false,
}
const complianceSlice = createSlice({
name: 'compliance',
initialState,
reducers: {
setComplianceDocs: (state, action: PayloadAction<ComplianceDoc[]>) => {
state.documents = action.payload
},
addComplianceDoc: (state, action: PayloadAction<ComplianceDoc>) => {
state.documents.push(action.payload)
},
updateComplianceDoc: (state, action: PayloadAction<ComplianceDoc>) => {
const index = state.documents.findIndex(d => d.id === action.payload.id)
if (index !== -1) {
state.documents[index] = action.payload
}
},
deleteComplianceDoc: (state, action: PayloadAction<string>) => {
state.documents = state.documents.filter(d => d.id !== action.payload)
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
},
})
export const { setComplianceDocs, addComplianceDoc, updateComplianceDoc, deleteComplianceDoc, setLoading } = complianceSlice.actions
export default complianceSlice.reducer

View File

@@ -0,0 +1,53 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface Expense {
id: string
workerId: string
workerName: string
category: string
amount: number
currency: string
date: string
status: string
description: string
receiptUrl?: string
billable: boolean
}
interface ExpensesState {
expenses: Expense[]
loading: boolean
}
const initialState: ExpensesState = {
expenses: [],
loading: false,
}
const expensesSlice = createSlice({
name: 'expenses',
initialState,
reducers: {
setExpenses: (state, action: PayloadAction<Expense[]>) => {
state.expenses = action.payload
},
addExpense: (state, action: PayloadAction<Expense>) => {
state.expenses.push(action.payload)
},
updateExpense: (state, action: PayloadAction<Expense>) => {
const index = state.expenses.findIndex(e => e.id === action.payload.id)
if (index !== -1) {
state.expenses[index] = action.payload
}
},
deleteExpense: (state, action: PayloadAction<string>) => {
state.expenses = state.expenses.filter(e => e.id !== action.payload)
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
},
})
export const { setExpenses, addExpense, updateExpense, deleteExpense, setLoading } = expensesSlice.actions
export default expensesSlice.reducer

View File

@@ -0,0 +1,53 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface Invoice {
id: string
clientName: string
amount: number
vat: number
total: number
status: string
dueDate: string
invoiceDate: string
paidDate?: string
currency: string
timesheetIds: string[]
}
interface InvoicesState {
invoices: Invoice[]
loading: boolean
}
const initialState: InvoicesState = {
invoices: [],
loading: false,
}
const invoicesSlice = createSlice({
name: 'invoices',
initialState,
reducers: {
setInvoices: (state, action: PayloadAction<Invoice[]>) => {
state.invoices = action.payload
},
addInvoice: (state, action: PayloadAction<Invoice>) => {
state.invoices.push(action.payload)
},
updateInvoice: (state, action: PayloadAction<Invoice>) => {
const index = state.invoices.findIndex(i => i.id === action.payload.id)
if (index !== -1) {
state.invoices[index] = action.payload
}
},
deleteInvoice: (state, action: PayloadAction<string>) => {
state.invoices = state.invoices.filter(i => i.id !== action.payload)
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
},
})
export const { setInvoices, addInvoice, updateInvoice, deleteInvoice, setLoading } = invoicesSlice.actions
export default invoicesSlice.reducer

View File

@@ -0,0 +1,61 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface Notification {
id: string
type: 'info' | 'warning' | 'error' | 'success'
title: string
message: string
timestamp: string
read: boolean
actionUrl?: string
}
interface NotificationsState {
notifications: Notification[]
unreadCount: number
}
const initialState: NotificationsState = {
notifications: [],
unreadCount: 0,
}
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
setNotifications: (state, action: PayloadAction<Notification[]>) => {
state.notifications = action.payload
state.unreadCount = action.payload.filter(n => !n.read).length
},
addNotification: (state, action: PayloadAction<Notification>) => {
state.notifications.unshift(action.payload)
if (!action.payload.read) {
state.unreadCount += 1
}
},
markAsRead: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find(n => n.id === action.payload)
if (notification && !notification.read) {
notification.read = true
state.unreadCount -= 1
}
},
markAllAsRead: (state) => {
state.notifications.forEach(n => {
n.read = true
})
state.unreadCount = 0
},
deleteNotification: (state, action: PayloadAction<string>) => {
const notification = state.notifications.find(n => n.id === action.payload)
if (notification && !notification.read) {
state.unreadCount -= 1
}
state.notifications = state.notifications.filter(n => n.id !== action.payload)
},
},
})
export const { setNotifications, addNotification, markAsRead, markAllAsRead, deleteNotification } = notificationsSlice.actions
export default notificationsSlice.reducer

View File

@@ -0,0 +1,47 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface PayrollRun {
id: string
periodStart: string
periodEnd: string
status: string
totalWorkers: number
totalAmount: number
processedDate?: string
paymentDate: string
}
interface PayrollState {
payrollRuns: PayrollRun[]
loading: boolean
}
const initialState: PayrollState = {
payrollRuns: [],
loading: false,
}
const payrollSlice = createSlice({
name: 'payroll',
initialState,
reducers: {
setPayrollRuns: (state, action: PayloadAction<PayrollRun[]>) => {
state.payrollRuns = action.payload
},
addPayrollRun: (state, action: PayloadAction<PayrollRun>) => {
state.payrollRuns.push(action.payload)
},
updatePayrollRun: (state, action: PayloadAction<PayrollRun>) => {
const index = state.payrollRuns.findIndex(p => p.id === action.payload.id)
if (index !== -1) {
state.payrollRuns[index] = action.payload
}
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
},
})
export const { setPayrollRuns, addPayrollRun, updatePayrollRun, setLoading } = payrollSlice.actions
export default payrollSlice.reducer

View File

@@ -0,0 +1,64 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface Shift {
date: string
start: string
end: string
hours: number
type: string
}
export interface Timesheet {
id: string
workerId: string
workerName: string
clientName: string
weekEnding: string
totalHours: number
regularHours: number
overtimeHours: number
status: string
rate: number
total: number
submittedDate: string
approvedDate?: string
shifts: Shift[]
}
interface TimesheetsState {
timesheets: Timesheet[]
loading: boolean
}
const initialState: TimesheetsState = {
timesheets: [],
loading: false,
}
const timesheetsSlice = createSlice({
name: 'timesheets',
initialState,
reducers: {
setTimesheets: (state, action: PayloadAction<Timesheet[]>) => {
state.timesheets = action.payload
},
addTimesheet: (state, action: PayloadAction<Timesheet>) => {
state.timesheets.push(action.payload)
},
updateTimesheet: (state, action: PayloadAction<Timesheet>) => {
const index = state.timesheets.findIndex(t => t.id === action.payload.id)
if (index !== -1) {
state.timesheets[index] = action.payload
}
},
deleteTimesheet: (state, action: PayloadAction<string>) => {
state.timesheets = state.timesheets.filter(t => t.id !== action.payload)
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
},
})
export const { setTimesheets, addTimesheet, updateTimesheet, deleteTimesheet, setLoading } = timesheetsSlice.actions
export default timesheetsSlice.reducer

View File

@@ -0,0 +1,34 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo'
interface UIState {
currentView: View
searchQuery: string
sidebarCollapsed: boolean
}
const initialState: UIState = {
currentView: 'dashboard',
searchQuery: '',
sidebarCollapsed: false,
}
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
setCurrentView: (state, action: PayloadAction<View>) => {
state.currentView = action.payload
},
setSearchQuery: (state, action: PayloadAction<string>) => {
state.searchQuery = action.payload
},
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed
},
},
})
export const { setCurrentView, setSearchQuery, toggleSidebar } = uiSlice.actions
export default uiSlice.reducer

25
src/store/store.ts Normal file
View File

@@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit'
import authReducer from './slices/authSlice'
import timesheetsReducer from './slices/timesheetsSlice'
import invoicesReducer from './slices/invoicesSlice'
import payrollReducer from './slices/payrollSlice'
import complianceReducer from './slices/complianceSlice'
import expensesReducer from './slices/expensesSlice'
import notificationsReducer from './slices/notificationsSlice'
import uiReducer from './slices/uiSlice'
export const store = configureStore({
reducer: {
auth: authReducer,
timesheets: timesheetsReducer,
invoices: invoicesReducer,
payroll: payrollReducer,
compliance: complianceReducer,
expenses: expensesReducer,
notifications: notificationsReducer,
ui: uiReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch