Code review and fixes
This commit is contained in:
@@ -6,6 +6,8 @@ import ToastProvider from './components/ToastProvider';
|
||||
import './styles/mobile.css';
|
||||
|
||||
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
|
||||
// TODO [CLEANUP #18]: DevTicker is a dev vanity widget that ships to prod.
|
||||
// Either gate with `import.meta.env.DEV` or remove from the footer.
|
||||
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
|
||||
|
||||
function elapsed(from) {
|
||||
@@ -101,7 +103,9 @@ const tabs = [
|
||||
{ id: 'violation', label: '+ New Violation' },
|
||||
];
|
||||
|
||||
// Responsive utility hook
|
||||
// TODO [MAJOR #8]: Move to src/hooks/useMediaQuery.js — this hook is duplicated
|
||||
// verbatim in Dashboard.jsx. Also remove `matches` from the useEffect dep array
|
||||
// (it changes inside the effect, which can cause a loop on strict-mode mount).
|
||||
function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -237,6 +241,8 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{/* TODO [MAJOR #9]: Inline <style> tags re-inject on every render and duplicate
|
||||
the same block from Dashboard.jsx. Move all shared mobile CSS to mobile.css */}
|
||||
<style>{mobileStyles}</style>
|
||||
<div style={s.app}>
|
||||
<nav style={s.nav} className="app-nav">
|
||||
@@ -248,7 +254,8 @@ export default function App() {
|
||||
<div className="nav-tabs">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
|
||||
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
|
||||
{/* TODO [MINOR #17]: first .replace('📊 ', '📊 ') replaces string with itself — no-op. Remove it. */}
|
||||
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,9 @@ export default function AuditLog({ onClose }) {
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const LIMIT = 50;
|
||||
|
||||
// TODO [MAJOR #5]: `offset` in useCallback deps causes the callback to be
|
||||
// re-created on each load-more, which triggers the filterType/filterAction
|
||||
// useEffect unexpectedly. Track offset in a useRef instead.
|
||||
const load = useCallback((reset = false) => {
|
||||
setLoading(true);
|
||||
const o = reset ? 0 : offset;
|
||||
@@ -121,7 +124,8 @@ export default function AuditLog({ onClose }) {
|
||||
axios.get('/api/audit', { params })
|
||||
.then(r => {
|
||||
const data = r.data;
|
||||
// Client-side action filter (cheap enough at this scale)
|
||||
// TODO [MINOR]: client-side action filter means server still fetches LIMIT
|
||||
// rows before filtering — add server-side `action` param to /api/audit.
|
||||
const filtered = filterAction ? data.filter(e => e.action === filterAction) : data;
|
||||
setEntries(prev => reset ? filtered : [...prev, ...filtered]);
|
||||
setHasMore(data.length === LIMIT);
|
||||
|
||||
@@ -29,7 +29,8 @@ function isAtRisk(points) {
|
||||
return boundary !== null && (boundary - points) <= AT_RISK_THRESHOLD;
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
// TODO [MAJOR #8]: Same hook is defined in App.jsx — extract to src/hooks/useMediaQuery.js
|
||||
// Also: `matches` in the dep array can cause a loop on strict-mode initial mount.
|
||||
function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -186,6 +187,7 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TODO [MAJOR #9]: Same mobileStyles block exists in App.jsx. Move to mobile.css */}
|
||||
<style>{mobileStyles}</style>
|
||||
<div style={s.wrap} className="dashboard-wrap">
|
||||
<div style={s.header} className="dashboard-header">
|
||||
|
||||
@@ -103,13 +103,12 @@ export default function EmployeeModal({ employeeId, onClose }) {
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
axios.get('/api/employees'),
|
||||
axios.get(`/api/employees/${employeeId}`),
|
||||
axios.get(`/api/employees/${employeeId}/score`),
|
||||
axios.get(`/api/violations/employee/${employeeId}?limit=100`),
|
||||
])
|
||||
.then(([empRes, scoreRes, violRes]) => {
|
||||
const emp = empRes.data.find((e) => e.id === employeeId);
|
||||
setEmployee(emp || null);
|
||||
setEmployee(empRes.data || null);
|
||||
setScore(scoreRes.data);
|
||||
setViolations(violRes.data);
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from './ToastProvider';
|
||||
|
||||
const s = {
|
||||
wrapper: { marginTop: '20px' },
|
||||
@@ -53,14 +54,23 @@ export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
|
||||
const [draft, setDraft] = useState(initialNotes || '');
|
||||
const [saved, setSaved] = useState(initialNotes || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveErr, setSaveErr] = useState('');
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveErr('');
|
||||
try {
|
||||
await axios.patch(`/api/employees/${employeeId}/notes`, { notes: draft });
|
||||
setSaved(draft);
|
||||
setEditing(false);
|
||||
if (onSaved) onSaved(draft);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.error || err.message || 'Failed to save notes';
|
||||
setSaveErr(msg);
|
||||
toast.error('Notes save failed: ' + msg);
|
||||
// Keep editing open so the user doesn't lose their changes
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -130,6 +140,11 @@ export default function EmployeeNotes({ employeeId, initialNotes, onSaved }) {
|
||||
placeholder="Free-text notes — one per line or comma-separated. Does not affect CPAS scoring."
|
||||
autoFocus
|
||||
/>
|
||||
{saveErr && (
|
||||
<div style={{ fontSize: '12px', color: '#ff7070', marginBottom: '6px' }}>
|
||||
✗ {saveErr}
|
||||
</div>
|
||||
)}
|
||||
<div style={s.actions}>
|
||||
<button style={s.saveBtn} onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Notes'}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Tier thresholds used to compute what tier an employee would drop to
|
||||
// after a given violation rolls off.
|
||||
// TODO [MINOR #10]: This TIER_THRESHOLDS array duplicates tiers defined in CpasBadge.jsx
|
||||
// and Dashboard.jsx. Export TIERS from CpasBadge.jsx and import here instead.
|
||||
const TIER_THRESHOLDS = [
|
||||
{ min: 30, label: 'Separation', color: '#ff1744' },
|
||||
{ min: 25, label: 'Final Decision', color: '#ff6d00' },
|
||||
|
||||
@@ -78,14 +78,12 @@ export default function NegateModal({ violation, onConfirm, onCancel }) {
|
||||
});
|
||||
};
|
||||
|
||||
// FIX: overlay click only closes on backdrop, NOT modal children
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget && onCancel) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={handleOverlayClick}>
|
||||
{/* FIX: stopPropagation prevents modal clicks from bubbling to overlay */}
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
<div style={s.header}>
|
||||
|
||||
@@ -35,6 +35,7 @@ const s = {
|
||||
const EMPTY_FORM = {
|
||||
employeeId: '', employeeName: '', department: '', supervisor: '', witnessName: '',
|
||||
violationType: '', incidentDate: '', incidentTime: '',
|
||||
// TODO [MAJOR #6]: `amount` and `minutesLate` are rendered but never sent to the API
|
||||
amount: '', minutesLate: '', location: '', additionalDetails: '', points: 1,
|
||||
acknowledgedBy: '', acknowledgedDate: '',
|
||||
};
|
||||
@@ -43,7 +44,7 @@ export default function ViolationForm() {
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [violation, setViolation] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [status, setStatus] = useState(null); // TODO [MAJOR #7]: remove — toast covers this
|
||||
const [lastViolId, setLastViolId] = useState(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
@@ -108,6 +109,7 @@ export default function ViolationForm() {
|
||||
setEmployees(empList.data);
|
||||
|
||||
toast.success(`Violation #${newId} recorded — click Download PDF to save the document.`);
|
||||
// TODO [MAJOR #7]: remove setStatus — toast above already covers this message
|
||||
setStatus({ ok: true, msg: `✓ Violation #${newId} recorded — click Download PDF to save the document.` });
|
||||
setForm(EMPTY_FORM);
|
||||
setViolation(null);
|
||||
|
||||
Reference in New Issue
Block a user