Add remediation plan and resolution date history tracking
New table compliance_item_history stores an append-only audit trail of changes to resolution_date and remediation_plan. The current values remain on compliance_items for fast VCL reporting queries (no double-counting). Backend: - Migration: creates compliance_item_history with indexes - PATCH /items/:hostname/metadata: records old→new in history before updating, accepts optional change_reason field (max 500 chars) - GET /items/:hostname: returns history array (last 10 entries, newest first) - POST /vcl/bulk-commit: records history for each changed field per hostname Frontend: - ComplianceDetailPanel: added change reason input below Save button - Added Change History section showing field changes with timestamps, usernames, old→new values, and reasons - Re-fetches detail after save to show updated history immediately Tests updated to match new transaction-based PATCH flow.
This commit is contained in:
@@ -112,7 +112,27 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('PATCH /items/:hostname/metadata', () => {
|
describe('PATCH /items/:hostname/metadata', () => {
|
||||||
it('happy path — updates resolution_date and remediation_plan', async () => {
|
it('happy path — updates resolution_date and remediation_plan', async () => {
|
||||||
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 2 });
|
// Mock client.query: first call = SELECT current values, second+ = INSERT history / UPDATE
|
||||||
|
const mockClient = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
// Override connect to return our mock client
|
||||||
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||||
|
// The first call from the handler is BEGIN, then SELECT, then inserts, then UPDATE, then COMMIT
|
||||||
|
mockClient.query = jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current values
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE items
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||||
resolution_date: '2026-06-15',
|
resolution_date: '2026-06-15',
|
||||||
@@ -143,7 +163,14 @@ describe('PATCH /items/:hostname/metadata', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 when hostname not found', async () => {
|
it('returns 404 when hostname not found', async () => {
|
||||||
mockPool.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
const mockClient = {
|
||||||
|
query: jest.fn()
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current values — empty = not found
|
||||||
|
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||||
|
|
||||||
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
const res = await request(server, 'PATCH', '/api/compliance/items/nonexistent-host/metadata', {
|
||||||
resolution_date: '2026-06-15',
|
resolution_date: '2026-06-15',
|
||||||
@@ -277,6 +304,9 @@ describe('Integration: full bulk upload flow (preview → commit)', () => {
|
|||||||
};
|
};
|
||||||
mockClient.query
|
mockClient.query
|
||||||
.mockResolvedValueOnce({}) // BEGIN
|
.mockResolvedValueOnce({}) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rows: [{ hostname: 'srv-001', resolution_date: null, remediation_plan: null }] }) // SELECT current values for all hostnames
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (resolution_date)
|
||||||
|
.mockResolvedValueOnce({ rowCount: 1 }) // INSERT history (remediation_plan)
|
||||||
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE srv-001
|
||||||
.mockResolvedValueOnce({}); // COMMIT
|
.mockResolvedValueOnce({}); // COMMIT
|
||||||
|
|
||||||
|
|||||||
44
backend/migrations/add_compliance_item_history.js
Normal file
44
backend/migrations/add_compliance_item_history.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting compliance_item_history migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_by TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ compliance_item_history table created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||||
|
ON compliance_item_history(hostname, field_name)
|
||||||
|
`);
|
||||||
|
console.log('✓ hostname/field_name index created');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||||
|
ON compliance_item_history(changed_at)
|
||||||
|
`);
|
||||||
|
console.log('✓ changed_at index created');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
|
|
||||||
|
// Self-execute when run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_vcl_reporting_columns.js',
|
'add_vcl_reporting_columns.js',
|
||||||
'add_vcl_vertical_metadata.js',
|
'add_vcl_vertical_metadata.js',
|
||||||
'add_vcl_multi_vertical.js',
|
'add_vcl_multi_vertical.js',
|
||||||
|
'add_compliance_item_history.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -598,10 +598,23 @@ function createComplianceRouter(upload) {
|
|||||||
WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname]
|
WHERE cn.hostname = $1 ORDER BY cn.created_at DESC`, [hostname]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch remediation history
|
||||||
|
let history = [];
|
||||||
|
try {
|
||||||
|
const { rows: historyRows } = await pool.query(
|
||||||
|
`SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||||
|
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
history = historyRows;
|
||||||
|
} catch (histErr) {
|
||||||
|
console.error('[Compliance] History fetch error:', histErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
const identity = metricRows.find(r => r.status === 'active') || metricRows[0];
|
||||||
// Return resolution_date and remediation_plan from the first active item (or any item)
|
// Return resolution_date and remediation_plan from the first active item (or any item)
|
||||||
const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null;
|
const resDate = identity.resolution_date ? (typeof identity.resolution_date === 'string' ? identity.resolution_date : identity.resolution_date.toISOString().slice(0, 10)) : null;
|
||||||
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes });
|
res.json({ hostname, ip_address: identity.ip_address || '', device_type: identity.device_type || '', team: identity.team || '', resolution_date: resDate, remediation_plan: identity.remediation_plan || '', metrics, notes, history });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
console.error('[Compliance] GET /items/:hostname error:', err.message);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@@ -851,7 +864,7 @@ function createComplianceRouter(upload) {
|
|||||||
const hostname = req.params.hostname;
|
const hostname = req.params.hostname;
|
||||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||||
|
|
||||||
const { resolution_date, remediation_plan } = req.body;
|
const { resolution_date, remediation_plan, change_reason } = req.body;
|
||||||
|
|
||||||
// Validate resolution_date: must be a valid ISO date string or null
|
// Validate resolution_date: must be a valid ISO date string or null
|
||||||
if (resolution_date !== undefined && resolution_date !== null) {
|
if (resolution_date !== undefined && resolution_date !== null) {
|
||||||
@@ -868,8 +881,11 @@ function createComplianceRouter(upload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Validate change_reason: optional, max 500 chars
|
||||||
// Build dynamic SET clause for provided fields only
|
if (change_reason !== undefined && change_reason !== null && change_reason.length > 500) {
|
||||||
|
return res.status(400).json({ error: 'Change reason exceeds 500 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
const setClauses = [];
|
const setClauses = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let paramIdx = 1;
|
let paramIdx = 1;
|
||||||
@@ -887,15 +903,61 @@ function createComplianceRouter(upload) {
|
|||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get current values before updating
|
||||||
|
const { rows: currentRows } = await client.query(
|
||||||
|
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||||
|
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||||
|
ORDER BY hostname, id DESC LIMIT 1`,
|
||||||
|
[hostname]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentRows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
client.release();
|
||||||
|
return res.status(404).json({ error: 'Device not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = currentRows[0];
|
||||||
|
const currentResDate = current.resolution_date
|
||||||
|
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||||
|
: null;
|
||||||
|
const currentPlan = current.remediation_plan || null;
|
||||||
|
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
||||||
|
|
||||||
|
// Insert history for each changed field
|
||||||
|
if (resolution_date !== undefined) {
|
||||||
|
const newVal = resolution_date || null;
|
||||||
|
if (currentResDate !== newVal) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
|
VALUES ($1, 'resolution_date', $2, $3, $4, $5)`,
|
||||||
|
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remediation_plan !== undefined) {
|
||||||
|
const newVal = remediation_plan || null;
|
||||||
|
if (currentPlan !== newVal) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
|
VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`,
|
||||||
|
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the items
|
||||||
values.push(hostname);
|
values.push(hostname);
|
||||||
const result = await pool.query(
|
const result = await client.query(
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
await client.query('COMMIT');
|
||||||
return res.status(404).json({ error: 'Device not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit({
|
logAudit({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -903,14 +965,17 @@ function createComplianceRouter(upload) {
|
|||||||
action: 'compliance_metadata_update',
|
action: 'compliance_metadata_update',
|
||||||
entityType: 'compliance_item',
|
entityType: 'compliance_item',
|
||||||
entityId: hostname,
|
entityId: hostname,
|
||||||
details: { resolution_date, remediation_plan },
|
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||||
ipAddress: req.ip,
|
ipAddress: req.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ updated: result.rowCount });
|
res.json({ updated: result.rowCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||||
res.status(500).json({ error: 'Failed to update device metadata' });
|
res.status(500).json({ error: 'Failed to update device metadata' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1280,6 +1345,22 @@ function createComplianceRouter(upload) {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Pre-fetch current values for all hostnames in the batch
|
||||||
|
const hostnames = changes.map(c => c.hostname);
|
||||||
|
const { rows: currentRows } = await client.query(
|
||||||
|
`SELECT DISTINCT ON (hostname) hostname, resolution_date, remediation_plan
|
||||||
|
FROM compliance_items WHERE status = 'active' AND hostname = ANY($1)
|
||||||
|
ORDER BY hostname, id DESC`,
|
||||||
|
[hostnames]
|
||||||
|
);
|
||||||
|
const currentData = new Map();
|
||||||
|
for (const row of currentRows) {
|
||||||
|
currentData.set(row.hostname, {
|
||||||
|
resolution_date: row.resolution_date ? (typeof row.resolution_date === 'string' ? row.resolution_date : row.resolution_date.toISOString().slice(0, 10)) : null,
|
||||||
|
remediation_plan: row.remediation_plan || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let committedCount = 0;
|
let committedCount = 0;
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const setClauses = [];
|
const setClauses = [];
|
||||||
@@ -1301,6 +1382,29 @@ function createComplianceRouter(upload) {
|
|||||||
|
|
||||||
if (setClauses.length === 0) continue;
|
if (setClauses.length === 0) continue;
|
||||||
|
|
||||||
|
// Record history for changed fields
|
||||||
|
const current = currentData.get(change.hostname) || { resolution_date: null, remediation_plan: null };
|
||||||
|
if (change.resolution_date !== undefined) {
|
||||||
|
const newVal = change.resolution_date || null;
|
||||||
|
if (current.resolution_date !== newVal) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
|
VALUES ($1, 'resolution_date', $2, $3, NULL, $4)`,
|
||||||
|
[change.hostname, current.resolution_date, newVal, req.user.username]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (change.remediation_plan !== undefined) {
|
||||||
|
const newVal = change.remediation_plan || null;
|
||||||
|
if (current.remediation_plan !== newVal) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
|
VALUES ($1, 'remediation_plan', $2, $3, NULL, $4)`,
|
||||||
|
[change.hostname, current.remediation_plan, newVal, req.user.username]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
values.push(change.hostname);
|
values.push(change.hostname);
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
// Metadata fields
|
// Metadata fields
|
||||||
const [resolutionDate, setResolutionDate] = useState('');
|
const [resolutionDate, setResolutionDate] = useState('');
|
||||||
const [remediationPlan, setRemediationPlan] = useState('');
|
const [remediationPlan, setRemediationPlan] = useState('');
|
||||||
|
const [changeReason, setChangeReason] = useState('');
|
||||||
const [metaSaving, setMetaSaving] = useState(false);
|
const [metaSaving, setMetaSaving] = useState(false);
|
||||||
const [metaError, setMetaError] = useState(null);
|
const [metaError, setMetaError] = useState(null);
|
||||||
|
|
||||||
@@ -58,14 +59,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
setMetaSaving(true);
|
setMetaSaving(true);
|
||||||
setMetaError(null);
|
setMetaError(null);
|
||||||
try {
|
try {
|
||||||
|
const body = { ...fields };
|
||||||
|
if (changeReason.trim()) body.change_reason = changeReason.trim();
|
||||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(fields),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
||||||
|
setChangeReason('');
|
||||||
|
// Re-fetch to get updated history
|
||||||
|
await fetchDetail();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMetaError(err.message);
|
setMetaError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -315,8 +321,54 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{metaError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem', fontFamily: 'monospace' }}>{metaError}</div>}
|
{metaError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem', fontFamily: 'monospace' }}>{metaError}</div>}
|
||||||
|
{/* Change reason input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={changeReason}
|
||||||
|
onChange={e => { if (e.target.value.length <= 500) setChangeReason(e.target.value); }}
|
||||||
|
placeholder="Reason for change (optional)"
|
||||||
|
style={{
|
||||||
|
width: '100%', marginTop: '0.5rem',
|
||||||
|
background: 'rgba(15,23,42,0.6)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
color: '#94A3B8',
|
||||||
|
padding: '0.35rem 0.5rem',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'rgba(20,184,166,0.4)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(255,255,255,0.08)'}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Change History */}
|
||||||
|
{detail.history && detail.history.length > 0 && (
|
||||||
|
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||||
|
{detail.history.map(h => (
|
||||||
|
<div key={h.id} style={{ marginBottom: '0.6rem', paddingBottom: '0.5rem', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||||
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#94A3B8' }}>
|
||||||
|
{h.field_name === 'resolution_date' ? '📅' : '📋'} {h.field_name.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.6rem', color: '#475569' }}>
|
||||||
|
{h.changed_at ? new Date(h.changed_at).toLocaleDateString() : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.15rem' }}>
|
||||||
|
<span style={{ color: '#EF4444' }}>{h.old_value || '—'}</span>
|
||||||
|
<span style={{ color: '#475569' }}> → </span>
|
||||||
|
<span style={{ color: '#10B981' }}>{h.field_name === 'remediation_plan' ? (h.new_value && h.new_value.length > 60 ? h.new_value.slice(0, 60) + '…' : (h.new_value || '—')) : (h.new_value || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.6rem', color: '#475569', marginTop: '0.1rem' }}>
|
||||||
|
{h.changed_by}{h.change_reason ? ` · ${h.change_reason}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||||
{detail.notes.length === 0 && (
|
{detail.notes.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user