fix teacher, parent and admin pages
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import {
|
||||
cancelIncidentFlag,
|
||||
closeIncidentFlag,
|
||||
createIncidentFlag,
|
||||
fetchFlagsFormMeta,
|
||||
fetchFlagsPending,
|
||||
fetchStudentsByGrade,
|
||||
type GradeOption,
|
||||
type IncidentFlagRow,
|
||||
} from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import { INCIDENT_DESCRIPTIONS, INCIDENT_ACTION_TEMPLATES } from './flagIncidentsConstants'
|
||||
import {
|
||||
descriptionForFlag,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
lastUpdaterCell,
|
||||
} from './flagRowUtils'
|
||||
import { normalizeIncidentKey } from './incidentLabels'
|
||||
|
||||
/** CI `flags/flags_management.php` — pending incidents, update state, add incident. */
|
||||
export function FlagsManagementPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const schoolYear = searchParams.get('school_year') ?? ''
|
||||
const semester = searchParams.get('semester') ?? ''
|
||||
|
||||
const [rows, setRows] = useState<IncidentFlagRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [flashOk, setFlashOk] = useState<string | null>(null)
|
||||
|
||||
const [grades, setGrades] = useState<GradeOption[]>([])
|
||||
const [gradesError, setGradesError] = useState<string | null>(null)
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [gradeId, setGradeId] = useState('')
|
||||
const [students, setStudents] = useState<Array<{ id: number; name: string }>>([])
|
||||
const [studentsLoading, setStudentsLoading] = useState(false)
|
||||
const [studentId, setStudentId] = useState('')
|
||||
const [incidentKey, setIncidentKey] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [descAutofill, setDescAutofill] = useState(false)
|
||||
const [addBusy, setAddBusy] = useState(false)
|
||||
|
||||
const [modalFlagId, setModalFlagId] = useState<number | null>(null)
|
||||
const [modalAction, setModalAction] = useState<'Closed' | 'Canceled' | null>(null)
|
||||
const [modalDescription, setModalDescription] = useState('')
|
||||
const [modalActionTaken, setModalActionTaken] = useState('')
|
||||
const [modalBusy, setModalBusy] = useState(false)
|
||||
|
||||
const loadPending = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchFlagsPending(searchParams)
|
||||
.then((data) => {
|
||||
setRows(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load incidents.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
loadPending()
|
||||
}, [loadPending])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFlagsFormMeta()
|
||||
.then((m) => {
|
||||
setGrades(m.grades ?? [])
|
||||
setGradesError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setGrades([])
|
||||
setGradesError(e instanceof ApiHttpError ? e.message : 'Unable to load grades.')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gradeId) {
|
||||
setStudents([])
|
||||
setStudentId('')
|
||||
return
|
||||
}
|
||||
const id = Number(gradeId)
|
||||
if (!Number.isFinite(id)) return
|
||||
setStudentsLoading(true)
|
||||
fetchStudentsByGrade(id)
|
||||
.then((list) => {
|
||||
setStudents(list)
|
||||
setStudentId('')
|
||||
})
|
||||
.catch(() => {
|
||||
setStudents([])
|
||||
setStudentId('')
|
||||
})
|
||||
.finally(() => setStudentsLoading(false))
|
||||
}, [gradeId])
|
||||
|
||||
function onIncidentChange(next: string) {
|
||||
setIncidentKey(next)
|
||||
if (!description.trim() || descAutofill) {
|
||||
const text = INCIDENT_DESCRIPTIONS[next] ?? ''
|
||||
setDescription(text)
|
||||
setDescAutofill(Boolean(text))
|
||||
}
|
||||
}
|
||||
|
||||
function onDescriptionInput(v: string) {
|
||||
setDescription(v)
|
||||
setDescAutofill(false)
|
||||
}
|
||||
|
||||
function openStateModal(flagId: number, action: 'Closed' | 'Canceled', incidentRaw: string) {
|
||||
setModalFlagId(flagId)
|
||||
setModalAction(action)
|
||||
setModalActionTaken('')
|
||||
const key = normalizeIncidentKey(incidentRaw)
|
||||
if (action === 'Closed') {
|
||||
const text =
|
||||
INCIDENT_ACTION_TEMPLATES[key] ?? INCIDENT_ACTION_TEMPLATES[incidentRaw] ?? ''
|
||||
setModalDescription(text)
|
||||
} else {
|
||||
setModalDescription('')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalFlagId(null)
|
||||
setModalAction(null)
|
||||
setModalDescription('')
|
||||
setModalActionTaken('')
|
||||
}
|
||||
|
||||
async function submitModal() {
|
||||
if (modalFlagId == null || !modalAction) return
|
||||
setModalBusy(true)
|
||||
try {
|
||||
const body = {
|
||||
state_description: modalDescription,
|
||||
action_taken: modalActionTaken,
|
||||
}
|
||||
if (modalAction === 'Closed') {
|
||||
await closeIncidentFlag(modalFlagId, body)
|
||||
} else {
|
||||
await cancelIncidentFlag(modalFlagId, body)
|
||||
}
|
||||
setFlashOk('Incident updated.')
|
||||
closeModal()
|
||||
loadPending()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Update failed.')
|
||||
} finally {
|
||||
setModalBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onFlagStateSelect(flag: IncidentFlagRow, next: string) {
|
||||
const id = flag.id
|
||||
if (id == null) return
|
||||
if (next === 'Closed' || next === 'Canceled') {
|
||||
openStateModal(id, next, String(flag.flag ?? ''))
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!gradeId || !studentId || !incidentKey || !description.trim()) return
|
||||
setAddBusy(true)
|
||||
setFlashOk(null)
|
||||
setError(null)
|
||||
try {
|
||||
await createIncidentFlag({
|
||||
grade: gradeId,
|
||||
student: studentId,
|
||||
flag: incidentKey,
|
||||
description: description.trim(),
|
||||
school_year: schoolYear || undefined,
|
||||
semester: semester || undefined,
|
||||
})
|
||||
setFlashOk('Incident saved.')
|
||||
setShowAddForm(false)
|
||||
setGradeId('')
|
||||
setStudentId('')
|
||||
setIncidentKey('')
|
||||
setDescription('')
|
||||
setDescAutofill(false)
|
||||
loadPending()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof ApiHttpError ? err.message : 'Could not save incident.')
|
||||
} finally {
|
||||
setAddBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Incidents Management</h2>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{flashOk ? (
|
||||
<div className="alert alert-success" role="alert">
|
||||
{flashOk}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
|
||||
<h4 className="mb-3">Pending Incidents</h4>
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updater</th>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Incident Type</th>
|
||||
<th>Description</th>
|
||||
<th>Datetime</th>
|
||||
<th>Incident State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((flag, i) => (
|
||||
<tr key={flag.id ?? i}>
|
||||
<td>{lastUpdaterCell(flag)}</td>
|
||||
<td>{flag.student_name ?? ''}</td>
|
||||
<td>{flag.grade ?? ''}</td>
|
||||
<td>{incidentTypeLabel(flag.flag)}</td>
|
||||
<td>{descriptionForFlag(flag)}</td>
|
||||
<td>{formatIncidentDatetime(flag.flag_datetime)}</td>
|
||||
<td>{flag.flag_state ?? ''}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value=""
|
||||
aria-label={`Update state for incident ${flag.id}`}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value
|
||||
if (v === 'Closed' || v === 'Canceled') onFlagStateSelect(flag, v)
|
||||
ev.currentTarget.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="">Select Action</option>
|
||||
<option value="Closed">Closed</option>
|
||||
<option value="Canceled">Canceled</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modalFlagId != null && modalAction ? (
|
||||
<>
|
||||
<div className="modal-backdrop fade show" />
|
||||
<div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal>
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Add Description</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={closeModal} />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Enter description here"
|
||||
value={modalDescription}
|
||||
onChange={(e) => setModalDescription(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<label className="form-label" htmlFor="flagActionTaken">
|
||||
Action Taken
|
||||
</label>
|
||||
<textarea
|
||||
id="flagActionTaken"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Enter action taken here"
|
||||
value={modalActionTaken}
|
||||
onChange={(e) => setModalActionTaken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={modalBusy}
|
||||
onClick={() => void submitModal()}
|
||||
>
|
||||
{modalBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="d-flex gap-2 mb-3 justify-content-end flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
>
|
||||
{showAddForm ? 'Hide form' : 'Add New Incident'}
|
||||
</button>
|
||||
<Link className="btn btn-info" to="/app/administrator/flags/incident-analysis">
|
||||
Incident Analysis
|
||||
</Link>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/processed">
|
||||
View Processed Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="container my-4 border rounded p-3 bg-light">
|
||||
<h5 className="mb-3">New incident</h5>
|
||||
{gradesError ? <div className="alert alert-warning small">{gradesError}</div> : null}
|
||||
<form className="row g-3" onSubmit={(e) => void onAddSubmit(e)}>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-grade">
|
||||
Grade
|
||||
</label>
|
||||
<select
|
||||
id="flags-grade"
|
||||
className="form-select"
|
||||
required
|
||||
value={gradeId}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
>
|
||||
<option value="">Select Grade</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-student">
|
||||
Student Name
|
||||
</label>
|
||||
<select
|
||||
id="flags-student"
|
||||
className="form-select"
|
||||
required
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
disabled={!gradeId || studentsLoading}
|
||||
>
|
||||
<option value="">{studentsLoading ? 'Loading…' : 'Select Student'}</option>
|
||||
{students.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label" htmlFor="flags-incident">
|
||||
Incident
|
||||
</label>
|
||||
<select
|
||||
id="flags-incident"
|
||||
className="form-select"
|
||||
required
|
||||
value={incidentKey}
|
||||
onChange={(e) => onIncidentChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select Incident</option>
|
||||
<option value="payment">Payment</option>
|
||||
<option value="attendance">Attendance</option>
|
||||
<option value="grade">Grade</option>
|
||||
<option value="behavior">Behavior (General)</option>
|
||||
<optgroup label="Level 1 - Minor (Teacher-managed)">
|
||||
<option value="talking_off_task">Talking/off-task</option>
|
||||
<option value="calling_out">Calling out</option>
|
||||
<option value="minor_disruption">Minor disruption</option>
|
||||
<option value="minor_dress_code">Minor dress code</option>
|
||||
<option value="forgetting_materials">Forgetting materials</option>
|
||||
</optgroup>
|
||||
<optgroup label="Level 2 - Moderate / Repeated">
|
||||
<option value="repeated_defiance">Repeated defiance</option>
|
||||
<option value="disrespectful_tone">Disrespectful tone</option>
|
||||
<option value="minor_profanity">Minor profanity</option>
|
||||
<option value="repeated_tardiness">Repeated tardiness</option>
|
||||
<option value="device_misuse">Device misuse</option>
|
||||
<option value="minor_conflict">Minor conflict/horseplay</option>
|
||||
</optgroup>
|
||||
<optgroup label="Level 3 - Serious (Immediate admin)">
|
||||
<option value="bullying_harassment">Bullying/harassment</option>
|
||||
<option value="fighting_aggression">Fighting/aggression</option>
|
||||
<option value="threats">Threats</option>
|
||||
<option value="theft">Theft</option>
|
||||
<option value="vandalism">Vandalism</option>
|
||||
<option value="sexual_harassment">Sexual harassment</option>
|
||||
<option value="serious_cyber_incident">Serious cyber incident</option>
|
||||
<option value="contraband">Contraband</option>
|
||||
<option value="weapons_drugs">Weapons/drugs</option>
|
||||
</optgroup>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="flags-desc">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="flags-desc"
|
||||
name="description"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
required
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<button type="submit" className="btn btn-primary" disabled={addBusy}>
|
||||
{addBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary ms-2"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchIncidentAnalysis, type IncidentAnalysisStudent } from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
descriptionForLog,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
} from './flagRowUtils'
|
||||
|
||||
/** CI `flags/incident_analysis.php` — per-student rollup + expandable logs. */
|
||||
export function IncidentAnalysisPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [students, setStudents] = useState<IncidentAnalysisStudent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchIncidentAnalysis(searchParams)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setStudents(data)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load incident analysis.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Incident Analysis</h2>
|
||||
<div className="d-flex gap-2 justify-content-end mb-3 flex-wrap">
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/management">
|
||||
Back to Pending Incidents
|
||||
</Link>
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/processed">
|
||||
View Processed Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Total Incidents</th>
|
||||
<th>Open</th>
|
||||
<th>Closed</th>
|
||||
<th>Canceled</th>
|
||||
<th>Last Incident</th>
|
||||
<th>Log & Follow-up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && students.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{students.map((student, si) => (
|
||||
<tr key={`${student.student_name}-${student.grade}-${si}`}>
|
||||
<td>{student.student_name ?? ''}</td>
|
||||
<td>{student.grade ?? ''}</td>
|
||||
<td>{student.total ?? 0}</td>
|
||||
<td>{student.open ?? 0}</td>
|
||||
<td>{student.closed ?? 0}</td>
|
||||
<td>{student.canceled ?? 0}</td>
|
||||
<td>{formatIncidentDatetime(student.last_incident)}</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>View log</summary>
|
||||
<div className="table-responsive mt-2">
|
||||
<table className="table table-sm table-bordered mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datetime</th>
|
||||
<th>Incident Type</th>
|
||||
<th>State</th>
|
||||
<th>Description</th>
|
||||
<th>Action Taken</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!student.logs?.length ? (
|
||||
<tr>
|
||||
<td colSpan={5}>No incident logs found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{student.logs?.map((log, li) => (
|
||||
<tr key={li}>
|
||||
<td>{formatIncidentDatetime(log.flag_datetime)}</td>
|
||||
<td>{incidentTypeLabel(log.flag)}</td>
|
||||
<td>{log.flag_state ?? ''}</td>
|
||||
<td>{descriptionForLog(log)}</td>
|
||||
<td>{log.action_taken ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchFlagsProcessed, type IncidentFlagRow } from '../../api/flags'
|
||||
import { AcademicFilterBar } from '../discounts/AcademicFilterBar'
|
||||
import {
|
||||
descriptionForFlag,
|
||||
formatIncidentDatetime,
|
||||
incidentTypeLabel,
|
||||
lastUpdaterCell,
|
||||
} from './flagRowUtils'
|
||||
|
||||
/** CI `flags/processed_flags.php` — processed / closed / canceled incidents. */
|
||||
export function ProcessedFlagsPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [rows, setRows] = useState<IncidentFlagRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetchFlagsProcessed(searchParams)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRows(data)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load processed incidents.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h2 className="text-center mt-4 mb-3">Processed Incidents</h2>
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Link className="btn btn-secondary" to="/app/administrator/flags/management">
|
||||
Back to Pending Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AcademicFilterBar />
|
||||
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
{loading ? <p className="text-muted">Loading…</p> : null}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last Updater</th>
|
||||
<th>Student Name</th>
|
||||
<th>Grade</th>
|
||||
<th>Incident Type</th>
|
||||
<th>Description</th>
|
||||
<th>Datetime</th>
|
||||
<th>Incident State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>No processed incidents found.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((flag, i) => (
|
||||
<tr key={flag.id ?? i}>
|
||||
<td>{lastUpdaterCell(flag)}</td>
|
||||
<td>{flag.student_name ?? ''}</td>
|
||||
<td>{flag.grade ?? ''}</td>
|
||||
<td>{incidentTypeLabel(flag.flag)}</td>
|
||||
<td>{descriptionForFlag(flag)}</td>
|
||||
<td>{formatIncidentDatetime(flag.flag_datetime)}</td>
|
||||
<td>{flag.flag_state ?? ''}</td>
|
||||
<td>{flag.action_taken ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Autofill for “action taken” when closing (CI `flags_management.php`). */
|
||||
export const INCIDENT_ACTION_TEMPLATES: Record<string, string> = {
|
||||
payment:
|
||||
'Reminder/clarify expectations → parent contact if needed → admin follow-up if unresolved.',
|
||||
attendance:
|
||||
'Log + warning → lunch detention/supervised study → parent contact → admin conference + attendance plan.',
|
||||
grade:
|
||||
'Clarify expectations → support/reteach → parent contact if pattern → admin referral if unresolved.',
|
||||
behavior:
|
||||
'Start small → reteach expectations → repair harm → escalate only for repeat or safety issues.',
|
||||
talking_off_task:
|
||||
'Reminder → seat change/separation → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
calling_out:
|
||||
'Reminder → seat change → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
minor_disruption:
|
||||
'Reminder → seat change/separation → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
minor_dress_code:
|
||||
'Reminder/clarify → reflection + reteach → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
forgetting_materials:
|
||||
'Reminder + reteach routines → reflection → parent contact + lunch detention/supervised study → admin referral (pattern).',
|
||||
repeated_defiance:
|
||||
'Reflection + restorative chat → parent contact + lunch detention → behavior contract + counselor check-in → admin referral (ISS if needed).',
|
||||
disrespectful_tone:
|
||||
'Warning + rephrase → reflection + apology plan → parent contact + lunch detention → admin referral if repeated.',
|
||||
minor_profanity:
|
||||
'Warning + rephrase → reflection + apology plan → parent contact + lunch detention → admin referral if repeated.',
|
||||
repeated_tardiness:
|
||||
'Log + warning → lunch detention/supervised study → parent contact → admin conference + attendance plan.',
|
||||
device_misuse:
|
||||
'Warning → device away/confiscate (end of day) → loss of privilege + lunch detention/supervised study → admin referral if repeated.',
|
||||
minor_conflict:
|
||||
'Reflection + restorative chat → parent contact + lunch detention → behavior contract + counselor check-in → admin referral if repeated.',
|
||||
bullying_harassment:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
fighting_aggression:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
threats:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
theft:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
vandalism:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
sexual_harassment:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
serious_cyber_incident:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
contraband:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
weapons_drugs:
|
||||
'Immediate removal + admin referral → parent contact + investigation → safety/restorative plan → consequence based on severity (lunch detention/ISS/OSS) + restitution when applicable.',
|
||||
other: '',
|
||||
}
|
||||
|
||||
/** Autofill for new incident description (CI `flags_management.php`). */
|
||||
export const INCIDENT_DESCRIPTIONS: Record<string, string> = {
|
||||
payment:
|
||||
'Payment-related concern, such as an overdue balance, missing tuition installment, or a fee that was not submitted on time.',
|
||||
attendance:
|
||||
'Attendance-related concern involving absences, patterns of late arrival, or missed instructional time affecting progress.',
|
||||
grade:
|
||||
'Grade-related concern where current performance is below expected level, missing work is affecting progress, or a trend needs attention.',
|
||||
behavior:
|
||||
'General behavior concern that does not fit a specific category but needs documentation for follow-up and support.',
|
||||
talking_off_task:
|
||||
'Student was talking or off-task during instruction or independent work, impacting focus or the learning environment.',
|
||||
calling_out: 'Student called out without permission or interrupted instruction, affecting classroom flow.',
|
||||
minor_disruption:
|
||||
'Student caused a minor disruption (noise, movement, or distractions) that interrupted learning briefly.',
|
||||
minor_dress_code:
|
||||
'Student was out of dress code in a minor way (e.g., small uniform issue) needing a reminder.',
|
||||
forgetting_materials:
|
||||
'Student did not bring required materials (books, supplies, device) needed for class activities.',
|
||||
repeated_defiance:
|
||||
'Student repeatedly refused to follow directions after being prompted, impacting class routines.',
|
||||
disrespectful_tone:
|
||||
'Student used a disrespectful tone or language toward staff or peers, requiring documentation.',
|
||||
minor_profanity:
|
||||
'Student used mild or inappropriate language that is not threatening but is still unacceptable.',
|
||||
repeated_tardiness:
|
||||
'Student has a pattern of arriving late to class or school, disrupting routines.',
|
||||
device_misuse:
|
||||
'Student used a phone or device inappropriately (off-task apps, messaging, or during instruction).',
|
||||
minor_conflict:
|
||||
'Student engaged in minor conflict or horseplay that could escalate if not addressed.',
|
||||
bullying_harassment:
|
||||
'Bullying or harassment behavior reported, including repeated targeted actions or intimidation.',
|
||||
fighting_aggression: 'Physical fighting or aggressive contact occurred or was attempted.',
|
||||
threats: 'Verbal or written threats made toward a person or group that raise safety concerns.',
|
||||
theft: 'Theft or attempted theft of property was reported.',
|
||||
vandalism: 'Intentional damage to property or school materials was reported.',
|
||||
sexual_harassment:
|
||||
'Sexual harassment reported, including inappropriate comments, gestures, or contact.',
|
||||
serious_cyber_incident:
|
||||
'Serious online incident reported (harassment, threats, or harmful digital behavior).',
|
||||
contraband: 'Possession of unauthorized or prohibited items on campus.',
|
||||
weapons_drugs: 'Possession or involvement with weapons or drugs reported.',
|
||||
other: '',
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { incidentLabel } from './incidentLabels'
|
||||
import type { IncidentFlagRow, IncidentLogRow } from '../../api/flags'
|
||||
|
||||
export function formatIncidentDatetime(iso?: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const yyyy = d.getFullYear()
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd}-${yyyy} ${hh}:${min}`
|
||||
}
|
||||
|
||||
export function lastUpdaterCell(flag: IncidentFlagRow): string {
|
||||
const st = flag.flag_state
|
||||
if (st === 'Open') {
|
||||
return String(flag.updated_by_open_name ?? flag.updated_by_open ?? 'N/A')
|
||||
}
|
||||
if (st === 'Closed') {
|
||||
return String(flag.updated_by_closed_name ?? flag.updated_by_closed ?? 'N/A')
|
||||
}
|
||||
if (st === 'Canceled') {
|
||||
return String(flag.updated_by_canceled_name ?? flag.updated_by_canceled ?? 'N/A')
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function descriptionForFlag(flag: IncidentFlagRow): string {
|
||||
const st = flag.flag_state
|
||||
if (st === 'Open') return flag.open_description ?? 'N/A'
|
||||
if (st === 'Closed') return flag.close_description ?? 'N/A'
|
||||
if (st === 'Canceled') return flag.cancel_description ?? 'N/A'
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function descriptionForLog(log: IncidentLogRow): string {
|
||||
const st = log.flag_state
|
||||
if (st === 'Open' && log.open_description) return String(log.open_description)
|
||||
if (st === 'Closed' && log.close_description) return String(log.close_description)
|
||||
if (st === 'Canceled' && log.cancel_description) return String(log.cancel_description)
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
export function incidentTypeLabel(flagKey: string | undefined): string {
|
||||
return incidentLabel(flagKey)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Maps stored `flag` keys to display labels (parity with CI `flags/*.php`). */
|
||||
export const INCIDENT_LABELS: Record<string, string> = {
|
||||
payment: 'Payment',
|
||||
attendance: 'Attendance',
|
||||
grade: 'Grade',
|
||||
behavior: 'Behavior (General)',
|
||||
talking_off_task: 'Talking/off-task',
|
||||
calling_out: 'Calling out',
|
||||
minor_disruption: 'Minor disruption',
|
||||
minor_dress_code: 'Minor dress code',
|
||||
forgetting_materials: 'Forgetting materials',
|
||||
repeated_defiance: 'Repeated defiance',
|
||||
disrespectful_tone: 'Disrespectful tone',
|
||||
minor_profanity: 'Minor profanity',
|
||||
repeated_tardiness: 'Repeated tardiness',
|
||||
device_misuse: 'Device misuse',
|
||||
minor_conflict: 'Minor conflict/horseplay',
|
||||
bullying_harassment: 'Bullying/harassment',
|
||||
fighting_aggression: 'Fighting/aggression',
|
||||
threats: 'Threats',
|
||||
theft: 'Theft',
|
||||
vandalism: 'Vandalism',
|
||||
sexual_harassment: 'Sexual harassment',
|
||||
serious_cyber_incident: 'Serious cyber incident',
|
||||
contraband: 'Contraband',
|
||||
weapons_drugs: 'Weapons/drugs',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
export function incidentLabel(flagKey: string | null | undefined): string {
|
||||
const v = (flagKey ?? '').trim()
|
||||
if (!v) return 'N/A'
|
||||
return INCIDENT_LABELS[v] ?? v
|
||||
}
|
||||
|
||||
export function normalizeIncidentKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
Reference in New Issue
Block a user