This commit is contained in:
@@ -14,7 +14,9 @@
|
|||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")",
|
"Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")",
|
||||||
"Bash(node --check auth.js)",
|
"Bash(node --check auth.js)",
|
||||||
"Bash(node --check db/database.js)"
|
"Bash(node --check db/database.js)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Bash(node --check lib/rolloff.js)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,73 +62,89 @@ const s = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ExpirationTimeline({ employeeId, currentPoints }) {
|
export default function ExpirationTimeline({ employeeId, currentPoints }) {
|
||||||
const [items, setItems] = useState([]);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios.get(`/api/employees/${employeeId}/expiration`)
|
axios.get(`/api/employees/${employeeId}/expiration`)
|
||||||
.then(r => setItems(r.data))
|
.then(r => setData(r.data))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [employeeId]);
|
}, [employeeId]);
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div style={s.wrapper}>
|
<div style={s.wrapper}>
|
||||||
<div style={s.sectionHd}>Point Expiration Timeline</div>
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
||||||
<div style={{ ...s.empty }}>Loading…</div>
|
<div style={{ ...s.empty }}>Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.length === 0) return (
|
const schedule = data?.schedule || [];
|
||||||
|
|
||||||
|
if (schedule.length === 0) return (
|
||||||
<div style={s.wrapper}>
|
<div style={s.wrapper}>
|
||||||
<div style={s.sectionHd}>Point Expiration Timeline</div>
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
||||||
<div style={s.empty}>No active violations — nothing to expire.</div>
|
<div style={s.empty}>No active points — nothing to roll off.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build running totals: after each violation expires, what's the remaining score?
|
// Points roll off 5 at a time (oldest first) after each 90 consecutive clean
|
||||||
let running = currentPoints || 0;
|
// days. Walk the schedule to project the score and tier after each event.
|
||||||
const projected = items.map(item => {
|
let running = data.active_points ?? currentPoints ?? 0;
|
||||||
const before = running;
|
const projected = schedule.map(ev => {
|
||||||
running = Math.max(0, running - item.points);
|
const before = running;
|
||||||
|
running = ev.points_after;
|
||||||
const tierBefore = getTier(before);
|
const tierBefore = getTier(before);
|
||||||
const tierAfter = getTier(running);
|
const tierAfter = getTier(running);
|
||||||
const dropped = tierAfter.min < tierBefore.min;
|
return {
|
||||||
return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped };
|
...ev,
|
||||||
|
pointsBefore: before,
|
||||||
|
tierBefore,
|
||||||
|
tierAfter,
|
||||||
|
tierDropped: tierAfter.min < tierBefore.min,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.wrapper}>
|
<div style={s.wrapper}>
|
||||||
<div style={s.sectionHd}>Point Expiration Timeline</div>
|
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
|
||||||
|
|
||||||
{projected.map((item) => {
|
<div style={{ ...s.empty, fontStyle: 'normal', marginBottom: '10px', color: '#9ca0b8' }}>
|
||||||
const color = urgencyColor(item.days_remaining);
|
5 points roll off after every 90 consecutive days with no new violation.
|
||||||
const pct = (item.days_remaining / 90) * 100;
|
Any new violation resets the countdown.
|
||||||
|
{data.last_violation_date && (
|
||||||
|
<> Clean since <strong style={{ color: '#f8f9fa' }}>{data.last_violation_date}</strong>.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projected.map((ev, i) => {
|
||||||
|
const color = urgencyColor(ev.days_remaining);
|
||||||
|
const pct = (ev.days_remaining / 90) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={s.row}>
|
<div key={ev.date} style={s.row}>
|
||||||
{/* Violation name */}
|
{/* Roll-off event label */}
|
||||||
<div style={s.name} title={item.violation_name}>{item.violation_name}</div>
|
<div style={s.name} title={`Roll-off #${i + 1}`}>Roll-off #{i + 1}</div>
|
||||||
|
|
||||||
{/* Points badge */}
|
{/* Points retired */}
|
||||||
<div style={s.pts}>−{item.points}</div>
|
<div style={s.pts}>−{ev.points_off}</div>
|
||||||
|
|
||||||
{/* Progress bar: how much of the 90 days has elapsed */}
|
{/* Progress bar: how much of the 90-day cycle has elapsed */}
|
||||||
<div style={s.bar(pct, color)}>
|
<div style={s.bar(pct, color)}>
|
||||||
<div style={s.barFill(pct, color)} />
|
<div style={s.barFill(pct, color)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Days remaining pill */}
|
{/* Days remaining pill */}
|
||||||
<div style={s.pill(color)}>
|
<div style={s.pill(color)}>
|
||||||
{item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`}
|
{ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expiry date */}
|
{/* Roll-off date */}
|
||||||
<div style={s.date}>{item.expires_on}</div>
|
<div style={s.date}>{ev.date}</div>
|
||||||
|
|
||||||
{/* Tier drop indicator */}
|
{/* Tier drop indicator */}
|
||||||
{item.tierDropped && (
|
{ev.tierDropped && (
|
||||||
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
|
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
|
||||||
↓ {item.tierAfter.label}
|
↓ {ev.tierAfter.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -138,16 +154,16 @@ export default function ExpirationTimeline({ employeeId, currentPoints }) {
|
|||||||
{/* Projection summary */}
|
{/* Projection summary */}
|
||||||
<div style={s.projBox}>
|
<div style={s.projBox}>
|
||||||
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
|
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
|
||||||
Projected score after each expiration
|
Projected score after each roll-off
|
||||||
</div>
|
</div>
|
||||||
{projected.map((item, i) => (
|
{projected.map((ev) => (
|
||||||
<div key={item.id} style={s.projRow}>
|
<div key={ev.date} style={s.projRow}>
|
||||||
<span style={{ color: '#9ca0b8' }}>{item.expires_on} — {item.violation_name}</span>
|
<span style={{ color: '#9ca0b8' }}>{ev.date} — −{ev.points_off} pts</span>
|
||||||
<span>
|
<span>
|
||||||
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span>
|
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{ev.points_after} pts</span>
|
||||||
{item.tierDropped && (
|
{ev.tierDropped && (
|
||||||
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}>
|
<span style={{ marginLeft: '8px', color: ev.tierAfter.color, fontWeight: 700 }}>
|
||||||
→ {item.tierAfter.label}
|
→ {ev.tierAfter.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export default function ViolationForm() {
|
|||||||
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
|
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
|
||||||
<CpasBadge points={intel.score.active_points} />
|
<CpasBadge points={intel.score.active_points} />
|
||||||
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
|
<span style={{ fontSize: '12px', color: '#9ca0b8' }}>
|
||||||
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days
|
{intel.score.violation_count} active violation{intel.score.violation_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+5
-11
@@ -98,17 +98,11 @@ db.exec(`CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
expires_at DATETIME NOT NULL
|
expires_at DATETIME NOT NULL
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
// Recreate view so it always filters negated rows
|
// The old `active_cpas_scores` view implemented a naive per-violation 90-day
|
||||||
db.exec(`DROP VIEW IF EXISTS active_cpas_scores;
|
// window. CPAS standing now follows the clean-cycle roll-off model (see
|
||||||
CREATE VIEW active_cpas_scores AS
|
// lib/rolloff.js), which is order-dependent and computed in JS, so the view is
|
||||||
SELECT
|
// retired here to keep a single source of truth.
|
||||||
employee_id,
|
db.exec('DROP VIEW IF EXISTS active_cpas_scores;');
|
||||||
SUM(points) AS active_points,
|
|
||||||
COUNT(*) AS violation_count
|
|
||||||
FROM violations
|
|
||||||
WHERE negated = 0
|
|
||||||
AND incident_date >= DATE('now', '-90 days')
|
|
||||||
GROUP BY employee_id;`);
|
|
||||||
|
|
||||||
console.log('[DB] Connected:', dbPath);
|
console.log('[DB] Connected:', dbPath);
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
+3
-10
@@ -38,13 +38,6 @@ CREATE TABLE IF NOT EXISTS violation_resolutions (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Active score: only non-negated violations in rolling 90 days
|
-- CPAS standing (active points + roll-off schedule) is computed in JS from the
|
||||||
CREATE VIEW IF NOT EXISTS active_cpas_scores AS
|
-- clean-cycle model in lib/rolloff.js; see db/database.js for why the old
|
||||||
SELECT
|
-- active_cpas_scores view was retired.
|
||||||
employee_id,
|
|
||||||
SUM(points) AS active_points,
|
|
||||||
COUNT(*) AS violation_count
|
|
||||||
FROM violations
|
|
||||||
WHERE negated = 0
|
|
||||||
AND incident_date >= DATE('now', '-90 days')
|
|
||||||
GROUP BY employee_id;
|
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
// ── CPAS point roll-off model ────────────────────────────────────────────────
|
||||||
|
// Handbook rule: points roll off only after the employee goes a full
|
||||||
|
// ROLLOFF_DAYS (90) with NO new violations. The clock is shared across all of an
|
||||||
|
// employee's active points and is RESET by any new non-negated violation. Each
|
||||||
|
// completed clean cycle removes ROLLOFF_POINTS (5), oldest violation first, and
|
||||||
|
// every successive roll-off requires another fresh clean cycle.
|
||||||
|
//
|
||||||
|
// This is intentionally NOT a per-violation "expires 90 days after its own
|
||||||
|
// incident date" window — a new violation pushes back roll-off for everything.
|
||||||
|
|
||||||
|
const ROLLOFF_POINTS = 5;
|
||||||
|
const ROLLOFF_DAYS = 90;
|
||||||
|
const MS_PER_DAY = 86400000;
|
||||||
|
|
||||||
|
// All dates are treated as UTC calendar days to match SQLite's DATE('now').
|
||||||
|
function dayValue(dateStr) {
|
||||||
|
const [y, m, d] = String(dateStr).slice(0, 10).split('-').map(Number);
|
||||||
|
return Date.UTC(y, m - 1, d);
|
||||||
|
}
|
||||||
|
function daysBetween(fromStr, toStr) {
|
||||||
|
return Math.round((dayValue(toStr) - dayValue(fromStr)) / MS_PER_DAY);
|
||||||
|
}
|
||||||
|
function addDays(dateStr, n) {
|
||||||
|
return new Date(dayValue(dateStr) + n * MS_PER_DAY).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an employee's CPAS standing as of `asOf` (default today).
|
||||||
|
//
|
||||||
|
// `violations` is an array of rows with at least { id, points, incident_date,
|
||||||
|
// negated }. Negated rows and rows dated after `asOf` are ignored.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// activePoints — points still counting against the employee
|
||||||
|
// totalPoints — sum of all (non-negated, on-or-before asOf) points
|
||||||
|
// rolledOffPoints — points already retired by completed clean cycles
|
||||||
|
// cycles — completed 90-day clean cycles since the last violation
|
||||||
|
// violationCount — non-negated violations still retaining points
|
||||||
|
// lastViolationDate — most recent non-negated incident date (anchors the clock)
|
||||||
|
// nextRolloffDate — date the next 5 points retire (null if nothing left)
|
||||||
|
// perViolation — oldest-first breakdown: each row + { rolledOff, activePoints }
|
||||||
|
// schedule — future roll-off events: { date, points_off, points_after, days_remaining }
|
||||||
|
function computeStanding(violations, asOf = todayStr()) {
|
||||||
|
const asOfVal = dayValue(asOf);
|
||||||
|
const active = violations.filter(
|
||||||
|
v => !v.negated && dayValue(v.incident_date) <= asOfVal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return {
|
||||||
|
activePoints: 0, totalPoints: 0, rolledOffPoints: 0, cycles: 0,
|
||||||
|
violationCount: 0, lastViolationDate: null, nextRolloffDate: null,
|
||||||
|
perViolation: [], schedule: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...active].sort((a, b) => {
|
||||||
|
const d = dayValue(a.incident_date) - dayValue(b.incident_date);
|
||||||
|
return d !== 0 ? d : (a.id - b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPoints = sorted.reduce((s, v) => s + v.points, 0);
|
||||||
|
const lastViolationDate = String(sorted[sorted.length - 1].incident_date).slice(0, 10);
|
||||||
|
const daysSince = daysBetween(lastViolationDate, asOf);
|
||||||
|
const cycles = daysSince > 0 ? Math.floor(daysSince / ROLLOFF_DAYS) : 0;
|
||||||
|
const rolledOffPoints = Math.min(totalPoints, cycles * ROLLOFF_POINTS);
|
||||||
|
const activePoints = totalPoints - rolledOffPoints;
|
||||||
|
|
||||||
|
// Retire points oldest-first.
|
||||||
|
let toRoll = rolledOffPoints;
|
||||||
|
const perViolation = sorted.map(v => {
|
||||||
|
const off = Math.min(v.points, toRoll);
|
||||||
|
toRoll -= off;
|
||||||
|
return { ...v, rolledOff: off, activePoints: v.points - off };
|
||||||
|
});
|
||||||
|
const violationCount = perViolation.filter(v => v.activePoints > 0).length;
|
||||||
|
|
||||||
|
// Project remaining roll-offs forward. Cycle N retires at lastViolation + N*90.
|
||||||
|
const schedule = [];
|
||||||
|
let remaining = activePoints;
|
||||||
|
let c = cycles;
|
||||||
|
while (remaining > 0) {
|
||||||
|
c += 1;
|
||||||
|
const date = addDays(lastViolationDate, c * ROLLOFF_DAYS);
|
||||||
|
const off = Math.min(ROLLOFF_POINTS, remaining);
|
||||||
|
remaining -= off;
|
||||||
|
schedule.push({
|
||||||
|
date,
|
||||||
|
points_off: off,
|
||||||
|
points_after: remaining,
|
||||||
|
days_remaining: daysBetween(asOf, date),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activePoints, totalPoints, rolledOffPoints, cycles, violationCount,
|
||||||
|
lastViolationDate,
|
||||||
|
nextRolloffDate: schedule.length ? schedule[0].date : null,
|
||||||
|
perViolation, schedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ROLLOFF_POINTS, ROLLOFF_DAYS,
|
||||||
|
computeStanding, daysBetween, addDays, todayStr,
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const db = require('./db/database');
|
const db = require('./db/database');
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const generatePdf = require('./pdf/generator');
|
const generatePdf = require('./pdf/generator');
|
||||||
|
const { computeStanding } = require('./lib/rolloff');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -33,6 +34,20 @@ function audit(action, entityType, entityId, performedBy, details) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CPAS standing helpers ────────────────────────────────────────────────────
|
||||||
|
// All point/roll-off math lives in lib/rolloff.js. These helpers just feed it
|
||||||
|
// the employee's non-negated violations and return the computed standing.
|
||||||
|
function loadViolations(employeeId) {
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, points, incident_date, negated, violation_name, violation_type, category
|
||||||
|
FROM violations
|
||||||
|
WHERE employee_id = ? AND negated = 0`
|
||||||
|
).all(employeeId);
|
||||||
|
}
|
||||||
|
function standingFor(employeeId, asOf) {
|
||||||
|
return computeStanding(loadViolations(employeeId), asOf);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Version info (written by Dockerfile at build time) ───────────────────────
|
// ── Version info (written by Dockerfile at build time) ───────────────────────
|
||||||
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
|
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
|
||||||
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
|
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
|
||||||
@@ -224,8 +239,8 @@ app.patch('/api/employees/:id/notes', (req, res) => {
|
|||||||
app.get('/api/employees/:id/score', (req, res) => {
|
app.get('/api/employees/:id/score', (req, res) => {
|
||||||
const empId = req.params.id;
|
const empId = req.params.id;
|
||||||
|
|
||||||
// Active points from the 90-day rolling view
|
// Active points from the clean-cycle roll-off model
|
||||||
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId);
|
const standing = standingFor(empId);
|
||||||
|
|
||||||
// Total violations (all time) and negated count
|
// Total violations (all time) and negated count
|
||||||
const totals = db.prepare(`
|
const totals = db.prepare(`
|
||||||
@@ -238,50 +253,57 @@ app.get('/api/employees/:id/score', (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
employee_id: empId,
|
employee_id: empId,
|
||||||
active_points: active ? active.active_points : 0,
|
active_points: standing.activePoints,
|
||||||
violation_count: active ? active.violation_count : 0,
|
violation_count: standing.violationCount,
|
||||||
total_violations: totals ? totals.total_violations : 0,
|
total_violations: totals ? totals.total_violations : 0,
|
||||||
negated_count: totals ? totals.negated_count : 0,
|
negated_count: totals ? totals.negated_count : 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expiration Timeline ──────────────────────────────────────────────────────
|
// ── Roll-off Timeline ────────────────────────────────────────────────────────
|
||||||
// GET /api/employees/:id/expiration — active violations sorted by roll-off date
|
// GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
|
||||||
// Returns each active violation with days_remaining until it exits the 90-day window.
|
// Points only retire after 90 consecutive days with no new violation; any new
|
||||||
|
// violation resets the clock. Returns the active total plus each future 5-point
|
||||||
|
// roll-off event (oldest points first) until the balance reaches zero.
|
||||||
app.get('/api/employees/:id/expiration', (req, res) => {
|
app.get('/api/employees/:id/expiration', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const standing = standingFor(req.params.id);
|
||||||
SELECT
|
res.json({
|
||||||
v.id,
|
active_points: standing.activePoints,
|
||||||
v.violation_name,
|
last_violation_date: standing.lastViolationDate,
|
||||||
v.violation_type,
|
next_rolloff_date: standing.nextRolloffDate,
|
||||||
v.category,
|
schedule: standing.schedule,
|
||||||
v.points,
|
});
|
||||||
v.incident_date,
|
|
||||||
DATE(v.incident_date, '+90 days') AS expires_on,
|
|
||||||
CAST(
|
|
||||||
JULIANDAY(DATE(v.incident_date, '+90 days')) -
|
|
||||||
JULIANDAY(DATE('now'))
|
|
||||||
AS INTEGER
|
|
||||||
) AS days_remaining
|
|
||||||
FROM violations v
|
|
||||||
WHERE v.employee_id = ?
|
|
||||||
AND v.negated = 0
|
|
||||||
AND v.incident_date >= DATE('now', '-90 days')
|
|
||||||
ORDER BY v.incident_date ASC
|
|
||||||
`).all(req.params.id);
|
|
||||||
res.json(rows);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
app.get('/api/dashboard', (req, res) => {
|
app.get('/api/dashboard', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const employees = db.prepare(
|
||||||
SELECT e.id, e.name, e.department, e.supervisor,
|
'SELECT id, name, department, supervisor FROM employees'
|
||||||
COALESCE(s.active_points, 0) AS active_points,
|
).all();
|
||||||
COALESCE(s.violation_count,0) AS violation_count
|
|
||||||
FROM employees e
|
// Pull every non-negated violation once, bucket by employee, then run the
|
||||||
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
|
// roll-off model per employee rather than issuing a query each.
|
||||||
ORDER BY active_points DESC, e.name ASC
|
const buckets = new Map();
|
||||||
`).all();
|
for (const v of db.prepare(
|
||||||
|
`SELECT id, employee_id, points, incident_date, negated
|
||||||
|
FROM violations WHERE negated = 0`
|
||||||
|
).all()) {
|
||||||
|
if (!buckets.has(v.employee_id)) buckets.set(v.employee_id, []);
|
||||||
|
buckets.get(v.employee_id).push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = employees.map(e => {
|
||||||
|
const standing = computeStanding(buckets.get(e.id) || []);
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
active_points: standing.activePoints,
|
||||||
|
violation_count: standing.violationCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) =>
|
||||||
|
b.active_points - a.active_points || a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,33 +331,32 @@ app.get('/api/violations/:id/amendments', (req, res) => {
|
|||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper: compute prior_active_points at time of insert
|
// Helper: compute prior_active_points at time of insert — the employee's active
|
||||||
|
// CPAS standing the instant BEFORE this incident, under the clean-cycle model.
|
||||||
|
// Uses only violations strictly earlier than this incident, evaluated as of the
|
||||||
|
// incident date.
|
||||||
function getPriorActivePoints(employeeId, incidentDate) {
|
function getPriorActivePoints(employeeId, incidentDate) {
|
||||||
const row = db.prepare(
|
const earlier = db.prepare(
|
||||||
`SELECT COALESCE(SUM(points),0) AS pts
|
`SELECT id, points, incident_date, negated
|
||||||
FROM violations
|
FROM violations
|
||||||
WHERE employee_id = ?
|
WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
|
||||||
AND negated = 0
|
).all(employeeId, incidentDate);
|
||||||
AND incident_date >= DATE(?, '-90 days')
|
return computeStanding(earlier, incidentDate).activePoints;
|
||||||
AND incident_date < ?`
|
|
||||||
).get(employeeId, incidentDate, incidentDate);
|
|
||||||
return row ? row.pts : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: after a back-dated insert, refresh snapshots on any existing
|
// Helper: after a back-dated insert, refresh snapshots on every later
|
||||||
// violations whose 90-day prior-window now includes the new (earlier)
|
// violation. Under the clean-cycle roll-off model an earlier incident raises
|
||||||
// incident_date. Without this, their PDFs would still show the pre-backdate
|
// the running total and shifts the roll-off clock for ALL subsequent
|
||||||
// "Prior Active Points" and miss the inserted earlier violation.
|
// violations, so their "Prior Active Points" snapshots can all change — not
|
||||||
// Snapshots are still immutable w.r.t. negate/restore — only timeline-
|
// just those within 90 days. Snapshots remain immutable w.r.t. negate/restore;
|
||||||
// rewriting events (back-dated inserts) trigger a refresh.
|
// only timeline-rewriting events (back-dated inserts) trigger a refresh.
|
||||||
function recomputeSnapshotsAfter(employeeId, incidentDate) {
|
function recomputeSnapshotsAfter(employeeId, incidentDate) {
|
||||||
const affected = db.prepare(`
|
const affected = db.prepare(`
|
||||||
SELECT id, incident_date, prior_active_points
|
SELECT id, incident_date, prior_active_points
|
||||||
FROM violations
|
FROM violations
|
||||||
WHERE employee_id = ?
|
WHERE employee_id = ?
|
||||||
AND incident_date > ?
|
AND incident_date > ?
|
||||||
AND incident_date <= DATE(?, '+90 days')
|
`).all(employeeId, incidentDate);
|
||||||
`).all(employeeId, incidentDate, incidentDate);
|
|
||||||
|
|
||||||
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
|
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
|
||||||
const changes = [];
|
const changes = [];
|
||||||
@@ -732,13 +753,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
|
|||||||
|
|
||||||
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
if (!violation) return res.status(404).json({ error: 'Violation not found' });
|
||||||
|
|
||||||
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
|
const standing = standingFor(violation.employee_id);
|
||||||
.get(violation.employee_id) || { active_points: 0, violation_count: 0 };
|
|
||||||
|
|
||||||
const scoreForPdf = {
|
const scoreForPdf = {
|
||||||
employee_id: violation.employee_id,
|
employee_id: violation.employee_id,
|
||||||
active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
|
active_points: violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
|
||||||
violation_count: active.violation_count,
|
violation_count: standing.violationCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const pdfBuffer = await generatePdf(violation, scoreForPdf);
|
const pdfBuffer = await generatePdf(violation, scoreForPdf);
|
||||||
|
|||||||
Reference in New Issue
Block a user