fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+455
View File
@@ -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>
)
}
+128
View File
@@ -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 &amp; 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>
)
}
+93
View File
@@ -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>
)
}
+98
View File
@@ -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: '',
}
+48
View File
@@ -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)
}
+43
View File
@@ -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, '')
}