Merge pull request 'feature/mobile-responsive' (#41) from feature/mobile-responsive into master

Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-03-08 22:22:42 -05:00
5 changed files with 769 additions and 36 deletions

314
MOBILE_RESPONSIVE.md Normal file
View File

@@ -0,0 +1,314 @@
# Mobile-Responsive Implementation Guide
## Overview
This document describes the mobile-responsive updates implemented for the CPAS Tracker application. The design targets **standard phones (375px+ width)** with graceful degradation for smaller devices.
## Key Changes
### 1. **Responsive Utility Stylesheet** (`client/src/styles/mobile.css`)
A centralized CSS file providing:
- Media query breakpoints (768px, 480px)
- Touch-friendly tap targets (min 44px height)
- iOS input zoom prevention (16px font size)
- Utility classes for mobile layouts
- Card-based layout helpers
- Horizontal scroll containers
**Utility Classes:**
- `.hide-mobile` - Hide on screens ≤768px
- `.hide-tablet` - Hide on screens ≤1024px
- `.mobile-full-width` - Full width on mobile
- `.mobile-stack` - Stack flex items vertically
- `.mobile-scroll-x` - Enable horizontal scrolling
- `.mobile-card` - Card layout container
- `.mobile-sticky-top` - Sticky header positioning
### 2. **App Navigation** (`client/src/App.jsx`)
**Desktop Behavior:**
- Horizontal navigation bar
- Logo left, tabs center, docs button right
- Full tab labels displayed
**Mobile Behavior (768px):**
- Logo centered with full width
- Tabs stacked horizontally below logo
- Docs button positioned absolutely top-right
- Shortened tab labels ("📊 Dashboard" → "📊")
- Flexible padding (40px → 16px)
**Features:**
- `useMediaQuery()` hook for responsive detection
- Dynamic style injection via `<style>` tag
- Separate mobile CSS classes for targeted overrides
### 3. **Dashboard Layout** (`client/src/components/Dashboard.jsx`)
**Desktop View:**
- Traditional HTML table layout
- 7 columns (Index, Employee, Dept, Supervisor, Tier, Points, Violations)
- Horizontal scrolling for overflow
**Mobile View (768px):**
- Switches to card-based layout (DashboardMobile component)
- Each employee = one card with vertical data rows
- Touch-optimized tap targets
- Improved readability with larger fonts
**Mobile Stat Cards:**
- 2 columns on phones (480px+)
- 1 column on small phones (<480px)
- Reduced font sizes (28px → 24px)
- Compact padding
**Toolbar Adjustments:**
- Search input: 260px → 100% width
- Buttons stack vertically
- Full-width button styling
### 4. **Mobile Dashboard Component** (`client/src/components/DashboardMobile.jsx`)
A dedicated mobile-optimized employee card component:
**Card Structure:**
```
+--------------------------------+
| Employee Name [Button] |
| [At Risk Badge if applicable] |
|--------------------------------|
| Tier / Standing: [Badge] |
| Active Points: [Large #] |
| 90-Day Violations: [#] |
| Department: [Name] |
| Supervisor: [Name] |
+--------------------------------+
```
**Visual Features:**
- At-risk employees: Gold border + dark gold background
- Touch-friendly employee name buttons
- Color-coded point displays matching tier colors
- Compact spacing (12px margins)
- Subtle shadows for depth
### 5. **Responsive Breakpoints**
| Breakpoint | Target Devices | Layout Changes |
|------------|----------------|----------------|
| **1024px** | Tablets & below | Reduced padding, simplified nav |
| **768px** | Phones (landscape) | Card layouts, stacked navigation |
| **480px** | Small phones | Single-column stats, minimal spacing |
| **375px** | iPhone SE/6/7/8 | Optimized for minimum supported width |
### 6. **Touch Optimization**
**Tap Target Sizes:**
- All buttons: 44px minimum height (iOS/Android guidelines)
- Form inputs: 44px minimum height
- Navigation tabs: 44px touch area
**Typography:**
- Form inputs: 16px font size (prevents iOS zoom-in on focus)
- Readable body text: 13-14px
- Headers scale down appropriately
**Scrolling:**
- `-webkit-overflow-scrolling: touch` for smooth momentum scrolling
- Horizontal scroll on tables (desktop fallback)
- Vertical card scrolling on mobile
## Implementation Details
### Media Query Hook
```javascript
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
```
**Usage:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
### Conditional Rendering Pattern
```javascript
{isMobile ? (
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
) : (
<table style={s.table}>
{/* Desktop table layout */}
</table>
)}
```
### Dynamic Style Injection
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.dashboard-wrap {
padding: 16px !important;
}
}
`;
return (
<>
<style>{mobileStyles}</style>
{/* Component JSX */}
</>
);
```
## Testing Checklist
### Desktop (>768px)
- [ ] Navigation displays horizontally
- [ ] Dashboard shows full table
- [ ] All columns visible
- [ ] Docs button on right side
- [ ] Full tab labels visible
### Tablet (768px - 1024px)
- [ ] Reduced padding maintains readability
- [ ] Stats cards wrap to 2-3 columns
- [ ] Table scrolls horizontally if needed
### Mobile Portrait (375px - 768px)
- [ ] Logo centered, tabs stacked
- [ ] Dashboard shows card layout
- [ ] Search input full width
- [ ] Buttons stack vertically
- [ ] Employee cards display all data
- [ ] Tap targets ≥44px
- [ ] No horizontal scroll required
### Small Mobile (<480px)
- [ ] Stat cards single column
- [ ] Text remains readable
- [ ] No layout breakage
- [ ] Footer wraps properly
### iOS-Specific
- [ ] Input focus doesn't zoom page (16px font)
- [ ] Smooth momentum scrolling
- [ ] Tap highlights work correctly
### Android-Specific
- [ ] Touch feedback visible
- [ ] Back button behavior correct
- [ ] Keyboard doesn't break layout
## Browser Support
- **Chrome/Edge:** 88+
- **Firefox:** 85+
- **Safari:** 14+
- **iOS Safari:** 14+
- **Chrome Android:** 88+
## Performance Considerations
1. **Media query hook** re-renders only on breakpoint changes, not continuous resize
2. **Card layout** renders fewer DOM elements than table on mobile
3. **CSS injection** happens once per component mount
4. **No external CSS libraries** (zero KB bundle increase)
## Future Enhancements
### Phase 2 (Optional)
- [ ] ViolationForm mobile optimization with multi-step wizard
- [ ] Modal responsive sizing and animations
- [ ] Swipe gestures for employee cards
- [ ] Pull-to-refresh on mobile
- [ ] Offline support with service workers
### Phase 3 (Advanced)
- [ ] Progressive Web App (PWA) capabilities
- [ ] Native app shell with Capacitor
- [ ] Biometric authentication
- [ ] Push notifications
## File Structure
```
client/src/
├── App.jsx # Updated with mobile nav
├── components/
│ ├── Dashboard.jsx # Responsive table/card switch
│ ├── DashboardMobile.jsx # Mobile card layout (NEW)
│ └── ... # Other components
└── styles/
└── mobile.css # Responsive utilities (NEW)
```
## Maintenance Notes
### Adding New Components
When creating new components, follow this pattern:
1. **Import mobile.css utility classes:**
```javascript
import '../styles/mobile.css';
```
2. **Use media query hook:**
```javascript
const isMobile = useMediaQuery('(max-width: 768px)');
```
3. **Provide mobile-specific styles:**
```javascript
const mobileStyles = `
@media (max-width: 768px) {
.my-component { /* mobile overrides */ }
}
`;
```
4. **Test on real devices** (Chrome DevTools is insufficient for touch testing)
### Debugging Tips
- Use Chrome DevTools Device Mode (Ctrl+Shift+M)
- Test on actual devices when possible
- Check console for media query match state
- Verify tap target sizes with Chrome Lighthouse audit
- Test keyboard behavior on Android
## Deployment
1. Merge `feature/mobile-responsive` into `master`
2. Rebuild client bundle: `cd client && npm run build`
3. Restart server
4. Clear browser cache (Ctrl+Shift+R)
5. Test on production URL with mobile devices
## Support
For issues or questions about mobile-responsive implementation:
- Check browser console for errors
- Verify `mobile.css` is loaded
- Test with different screen sizes
- Review media query breakpoints
---
**Branch:** `feature/mobile-responsive`
**Target Width:** 375px+ (standard phones)
**Last Updated:** March 8, 2026
**Maintainer:** Jason Stedwell

View File

@@ -3,6 +3,7 @@ import ViolationForm from './components/ViolationForm';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import ReadmeModal from './components/ReadmeModal'; import ReadmeModal from './components/ReadmeModal';
import ToastProvider from './components/ToastProvider'; import ToastProvider from './components/ToastProvider';
import './styles/mobile.css';
const REPO_URL = 'https://git.alwisp.com/jason/cpas'; const REPO_URL = 'https://git.alwisp.com/jason/cpas';
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00'); const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
@@ -56,8 +57,19 @@ function AppFooter({ version }) {
0%, 100% { opacity: 1; transform: scale(1); } 0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); } 50% { opacity: 0.4; transform: scale(0.75); }
} }
/* Mobile-specific footer adjustments */
@media (max-width: 768px) {
.footer-content {
flex-wrap: wrap;
justify-content: center;
font-size: 10px;
padding: 10px 16px;
gap: 8px;
}
}
`}</style> `}</style>
<footer style={sf.footer}> <footer style={sf.footer} className="footer-content">
<span style={sf.copy}>&copy; {year} Jason Stedwell</span> <span style={sf.copy}>&copy; {year} Jason Stedwell</span>
<span style={sf.sep}>&middot;</span> <span style={sf.sep}>&middot;</span>
<DevTicker /> <DevTicker />
@@ -89,6 +101,19 @@ const tabs = [
{ id: 'violation', label: '+ New Violation' }, { id: 'violation', label: '+ New Violation' },
]; ];
// Responsive utility hook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const s = { const s = {
app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' }, app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' },
nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' }, nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' },
@@ -121,6 +146,58 @@ const s = {
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' }, card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
}; };
// Mobile-responsive style overrides
const mobileStyles = `
@media (max-width: 768px) {
.app-nav {
padding: 0 16px !important;
flex-wrap: wrap;
justify-content: center;
}
.logo-wrap {
margin-right: 0 !important;
padding: 12px 0 !important;
width: 100%;
justify-content: center;
border-bottom: 1px solid #1a1b22;
}
.nav-tabs {
display: flex;
width: 100%;
justify-content: space-around;
}
.nav-tab {
flex: 1;
text-align: center;
padding: 14px 8px !important;
font-size: 13px !important;
}
.docs-btn {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 10px !important;
font-size: 11px !important;
}
.docs-btn span:first-child {
display: none;
}
.main-card {
margin: 12px !important;
border-radius: 8px !important;
}
}
@media (max-width: 480px) {
.logo-text {
font-size: 16px !important;
}
.logo-img {
height: 24px !important;
}
}
`;
const sf = { const sf = {
footer: { footer: {
borderTop: '1px solid #1a1b22', borderTop: '1px solid #1a1b22',
@@ -149,6 +226,7 @@ export default function App() {
const [tab, setTab] = useState('dashboard'); const [tab, setTab] = useState('dashboard');
const [showReadme, setShowReadme] = useState(false); const [showReadme, setShowReadme] = useState(false);
const [version, setVersion] = useState(null); const [version, setVersion] = useState(null);
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => { useEffect(() => {
fetch('/version.json') fetch('/version.json')
@@ -159,26 +237,29 @@ export default function App() {
return ( return (
<ToastProvider> <ToastProvider>
<style>{mobileStyles}</style>
<div style={s.app}> <div style={s.app}>
<nav style={s.nav}> <nav style={s.nav} className="app-nav">
<div style={s.logoWrap}> <div style={s.logoWrap} className="logo-wrap">
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} /> <img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} className="logo-img" />
<div style={s.logoText}>CPAS Tracker</div> <div style={s.logoText} className="logo-text">CPAS Tracker</div>
</div> </div>
{tabs.map(t => ( <div className="nav-tabs">
<button key={t.id} style={s.tab(tab === t.id)} onClick={() => setTab(t.id)}> {tabs.map(t => (
{t.label} <button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
</button> {isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
))} </button>
))}
</div>
<button style={s.docsBtn} onClick={() => setShowReadme(true)} title="Open admin documentation"> <button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
<span>?</span> Docs <span>?</span> Docs
</button> </button>
</nav> </nav>
<div style={s.main}> <div style={s.main}>
<div style={s.card}> <div style={s.card} className="main-card">
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />} {tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
import CpasBadge, { getTier } from './CpasBadge'; import CpasBadge, { getTier } from './CpasBadge';
import EmployeeModal from './EmployeeModal'; import EmployeeModal from './EmployeeModal';
import AuditLog from './AuditLog'; import AuditLog from './AuditLog';
import DashboardMobile from './DashboardMobile';
const AT_RISK_THRESHOLD = 2; const AT_RISK_THRESHOLD = 2;
@@ -28,13 +29,26 @@ function isAtRisk(points) {
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD; return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
} }
// Media query hook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) setMatches(media.matches);
const listener = () => setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [matches, query]);
return matches;
}
const s = { const s = {
wrap: { padding: '32px 40px', color: '#f8f9fa' }, wrap: { padding: '32px 40px', color: '#f8f9fa' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px', flexWrap: 'wrap', gap: '12px' },
title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' }, title: { fontSize: '24px', fontWeight: 700, color: '#f8f9fa' },
subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' }, subtitle: { fontSize: '13px', color: '#b5b5c0', marginTop: '3px' },
statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' }, statsRow: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '28px' },
statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #30313f', borderRadius: '8px', padding: '16px', textAlign: 'center' }, statCard: { flex: '1', minWidth: '140px', background: '#181924', border: '1px solid #303136', borderRadius: '8px', padding: '16px', textAlign: 'center' },
statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' }, statNum: { fontSize: '28px', fontWeight: 800, color: '#f8f9fa' },
statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' }, statLbl: { fontSize: '11px', color: '#b5b5c0', marginTop: '4px' },
search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' }, search: { padding: '10px 14px', border: '1px solid #333544', borderRadius: '6px', fontSize: '14px', width: '260px', background: '#050608', color: '#f8f9fa' },
@@ -49,6 +63,55 @@ const s = {
auditBtn: { padding: '9px 18px', background: 'none', color: '#9ca0b8', border: '1px solid #2a2b3a', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }, auditBtn: { padding: '9px 18px', background: 'none', color: '#9ca0b8', border: '1px solid #2a2b3a', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' },
}; };
// Mobile styles
const mobileStyles = `
@media (max-width: 768px) {
.dashboard-wrap {
padding: 16px !important;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start !important;
}
.dashboard-title {
font-size: 20px !important;
}
.dashboard-subtitle {
font-size: 12px !important;
}
.dashboard-stats {
gap: 10px !important;
}
.dashboard-stat-card {
min-width: calc(50% - 5px) !important;
padding: 12px !important;
}
.stat-num {
font-size: 24px !important;
}
.stat-lbl {
font-size: 10px !important;
}
.toolbar-right {
width: 100%;
flex-direction: column;
}
.search-input {
width: 100% !important;
}
.toolbar-btn {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.dashboard-stat-card {
min-width: 100% !important;
}
}
`;
export default function Dashboard() { export default function Dashboard() {
const [employees, setEmployees] = useState([]); const [employees, setEmployees] = useState([]);
const [filtered, setFiltered] = useState([]); const [filtered, setFiltered] = useState([]);
@@ -56,6 +119,7 @@ export default function Dashboard() {
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null);
const [showAudit, setShowAudit] = useState(false); const [showAudit, setShowAudit] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isMobile = useMediaQuery('(max-width: 768px)');
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@@ -82,49 +146,53 @@ export default function Dashboard() {
return ( return (
<> <>
<div style={s.wrap}> <style>{mobileStyles}</style>
<div style={s.header}> <div style={s.wrap} className="dashboard-wrap">
<div style={s.header} className="dashboard-header">
<div> <div>
<div style={s.title}>Company Dashboard</div> <div style={s.title} className="dashboard-title">Company Dashboard</div>
<div style={s.subtitle}>Click any employee name to view their full profile</div> <div style={s.subtitle} className="dashboard-subtitle">Click any employee name to view their full profile</div>
</div> </div>
<div style={s.toolbarRight}> <div style={s.toolbarRight} className="toolbar-right">
<input <input
style={s.search} style={s.search}
className="search-input"
placeholder="Search name, dept, supervisor…" placeholder="Search name, dept, supervisor…"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
/> />
<button style={s.auditBtn} onClick={() => setShowAudit(true)}>📋 Audit Log</button> <button style={s.auditBtn} className="toolbar-btn" onClick={() => setShowAudit(true)}>📋 Audit Log</button>
<button style={s.refreshBtn} onClick={load}> Refresh</button> <button style={s.refreshBtn} className="toolbar-btn" onClick={load}> Refresh</button>
</div> </div>
</div> </div>
<div style={s.statsRow}> <div style={s.statsRow} className="dashboard-stats">
<div style={s.statCard}> <div style={s.statCard} className="dashboard-stat-card">
<div style={s.statNum}>{employees.length}</div> <div style={s.statNum} className="stat-num">{employees.length}</div>
<div style={s.statLbl}>Total Employees</div> <div style={s.statLbl} className="stat-lbl">Total Employees</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #28a745' }}> <div style={{ ...s.statCard, borderTop: '3px solid #28a745' }} className="dashboard-stat-card">
<div style={{ ...s.statNum, color: '#6ee7b7' }}>{cleanCount}</div> <div style={{ ...s.statNum, color: '#6ee7b7' }} className="stat-num">{cleanCount}</div>
<div style={s.statLbl}>Elite Standing (0 pts)</div> <div style={s.statLbl} className="stat-lbl">Elite Standing (0 pts)</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #d4af37' }}> <div style={{ ...s.statCard, borderTop: '3px solid #d4af37' }} className="dashboard-stat-card">
<div style={{ ...s.statNum, color: '#ffd666' }}>{activeCount}</div> <div style={{ ...s.statNum, color: '#ffd666' }} className="stat-num">{activeCount}</div>
<div style={s.statLbl}>With Active Points</div> <div style={s.statLbl} className="stat-lbl">With Active Points</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #ffb020' }}> <div style={{ ...s.statCard, borderTop: '3px solid #ffb020' }} className="dashboard-stat-card">
<div style={{ ...s.statNum, color: '#ffdf8a' }}>{atRiskCount}</div> <div style={{ ...s.statNum, color: '#ffdf8a' }} className="stat-num">{atRiskCount}</div>
<div style={s.statLbl}>At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div> <div style={s.statLbl} className="stat-lbl">At Risk ({AT_RISK_THRESHOLD} pts to next tier)</div>
</div> </div>
<div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }}> <div style={{ ...s.statCard, borderTop: '3px solid #c0392b' }} className="dashboard-stat-card">
<div style={{ ...s.statNum, color: '#ff8a80' }}>{maxPoints}</div> <div style={{ ...s.statNum, color: '#ff8a80' }} className="stat-num">{maxPoints}</div>
<div style={s.statLbl}>Highest Active Score</div> <div style={s.statLbl} className="stat-lbl">Highest Active Score</div>
</div> </div>
</div> </div>
{loading ? ( {loading ? (
<p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading</p> <p style={{ color: '#77798a', textAlign: 'center', padding: '40px' }}>Loading</p>
) : isMobile ? (
<DashboardMobile employees={filtered} onEmployeeClick={setSelectedId} />
) : ( ) : (
<table style={s.table}> <table style={s.table}>
<thead> <thead>

View File

@@ -0,0 +1,157 @@
import React from 'react';
import CpasBadge, { getTier } from './CpasBadge';
const AT_RISK_THRESHOLD = 2;
const TIERS = [
{ min: 0, max: 4 },
{ min: 5, max: 9 },
{ min: 10, max: 14 },
{ min: 15, max: 19 },
{ min: 20, max: 24 },
{ min: 25, max: 29 },
{ min: 30, max: 999 },
];
function nextTierBoundary(points) {
for (const t of TIERS) {
if (points >= t.min && points <= t.max && t.max < 999) return t.max + 1;
}
return null;
}
function isAtRisk(points) {
const boundary = nextTierBoundary(points);
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
}
const s = {
card: {
background: '#181924',
border: '1px solid #2a2b3a',
borderRadius: '10px',
padding: '16px',
marginBottom: '12px',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
},
cardAtRisk: {
background: '#181200',
border: '1px solid #d4af37',
},
row: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
},
rowLast: {
borderBottom: 'none',
},
label: {
fontSize: '11px',
fontWeight: 600,
color: '#9ca0b8',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
value: {
fontSize: '14px',
fontWeight: 600,
color: '#f8f9fa',
textAlign: 'right',
},
name: {
fontSize: '16px',
fontWeight: 700,
color: '#d4af37',
marginBottom: '8px',
cursor: 'pointer',
textDecoration: 'underline dotted',
background: 'none',
border: 'none',
padding: 0,
textAlign: 'left',
width: '100%',
},
atRiskBadge: {
display: 'inline-block',
marginTop: '4px',
padding: '3px 8px',
borderRadius: '10px',
fontSize: '10px',
fontWeight: 700,
background: '#3b2e00',
color: '#ffd666',
border: '1px solid #d4af37',
},
points: {
fontSize: '28px',
fontWeight: 800,
textAlign: 'center',
margin: '8px 0',
},
};
export default function DashboardMobile({ employees, onEmployeeClick }) {
if (!employees || employees.length === 0) {
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#77798a', fontStyle: 'italic' }}>
No employees found.
</div>
);
}
return (
<div style={{ padding: '12px' }}>
{employees.map((emp) => {
const risk = isAtRisk(emp.active_points);
const tier = getTier(emp.active_points);
const boundary = nextTierBoundary(emp.active_points);
const cardStyle = risk ? { ...s.card, ...s.cardAtRisk } : s.card;
return (
<div key={emp.id} style={cardStyle}>
<button style={s.name} onClick={() => onEmployeeClick(emp.id)}>
{emp.name}
</button>
{risk && (
<div style={s.atRiskBadge}>
{boundary - emp.active_points} pt{boundary - emp.active_points > 1 ? 's' : ''} to {getTier(boundary).label.split('—')[0].trim()}
</div>
)}
<div style={{ ...s.row, marginTop: '12px' }}>
<span style={s.label}>Tier / Standing</span>
<span style={s.value}><CpasBadge points={emp.active_points} /></span>
</div>
<div style={s.row}>
<span style={s.label}>Active Points</span>
<span style={{ ...s.points, color: tier.color }}>{emp.active_points}</span>
</div>
<div style={s.row}>
<span style={s.label}>90-Day Violations</span>
<span style={s.value}>{emp.violation_count}</span>
</div>
{emp.department && (
<div style={s.row}>
<span style={s.label}>Department</span>
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.department}</span>
</div>
)}
{emp.supervisor && (
<div style={{ ...s.row, ...s.rowLast }}>
<span style={s.label}>Supervisor</span>
<span style={{ ...s.value, color: '#c0c2d6' }}>{emp.supervisor}</span>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,113 @@
/* Mobile-Responsive Utilities for CPAS Tracker */
/* Target: Standard phones 375px+ with graceful degradation */
/* Base responsive utilities */
@media (max-width: 768px) {
/* Hide scrollbars but keep functionality */
* {
-webkit-overflow-scrolling: touch;
}
/* Touch-friendly tap targets (min 44px) */
button, a, input, select {
min-height: 44px;
}
/* Improve form input sizing on mobile */
input, select, textarea {
font-size: 16px !important; /* Prevents iOS zoom on focus */
}
}
/* Tablet and below */
@media (max-width: 1024px) {
.hide-tablet {
display: none !important;
}
}
/* Mobile portrait and landscape */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
.mobile-full-width {
width: 100% !important;
}
.mobile-text-center {
text-align: center !important;
}
.mobile-no-padding {
padding: 0 !important;
}
.mobile-small-padding {
padding: 12px !important;
}
/* Stack flex containers vertically */
.mobile-stack {
flex-direction: column !important;
}
/* Allow horizontal scroll for tables */
.mobile-scroll-x {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
/* Card-based layout helpers */
.mobile-card {
display: block !important;
padding: 16px;
margin-bottom: 12px;
border-radius: 8px;
background: #181924;
border: 1px solid #2a2b3a;
}
.mobile-card-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #1c1d29;
}
.mobile-card-row:last-child {
border-bottom: none;
}
.mobile-card-label {
font-weight: 600;
color: #9ca0b8;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mobile-card-value {
font-weight: 600;
color: #f8f9fa;
text-align: right;
}
}
/* Small mobile phones */
@media (max-width: 480px) {
.hide-small-mobile {
display: none !important;
}
}
/* Utility for sticky positioning on mobile */
@media (max-width: 768px) {
.mobile-sticky-top {
position: sticky;
top: 0;
z-index: 100;
background: #000000;
}
}