343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
// Admin: departments, their stations and the AD groups each one needs (phase 9).
|
|||
|
|
import { useState, type FormEvent } from "react";
|
||
|
|
import { admin9Api, type AdminDepartment, type DeptAdded, type DeptGroup } from "../../api/admin";
|
||
|
|
import { errorText } from "../../lib/format";
|
||
|
|
import { useLoad } from "../../lib/useLoad";
|
||
|
|
import { Notice, Problem, type Msg } from "./common";
|
||
|
|
|
||
|
|
const RX_CODE = /^[0-9A-Z]{2,4}$/;
|
||
|
|
const RX_SLUG = /^[A-Za-z][A-Za-z0-9]{1,30}$/;
|
||
|
|
const SHOP_LEVELS = ["Worker", "Lead", "Supervisor"] as const;
|
||
|
|
|
||
|
|
function OuPill({ g }: { g: DeptGroup }) {
|
||
|
|
if (g.in_ou === true) return <span className="pill st-ok">In AD</span>;
|
||
|
|
if (g.in_ou === false) return <span className="pill st-bad">Missing in AD</span>;
|
||
|
|
return <span className="pill st-wait">Not checked</span>;
|
||
|
|
}
|
||
|
|
|
||
|
|
type StationDraft = { id?: number; name: string; active: boolean };
|
||
|
|
|
||
|
|
function DeptEditor({ d, onSaved, onClose }: { d: AdminDepartment; onSaved: () => void; onClose: () => void }) {
|
||
|
|
const [name, setName] = useState(d.name);
|
||
|
|
const [active, setActive] = useState(d.active);
|
||
|
|
const [stations, setStations] = useState<StationDraft[]>(
|
||
|
|
[...d.stations].sort((a, b) => a.sort - b.sort).map((s) => ({ id: s.id, name: s.name, active: s.active })),
|
||
|
|
);
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [msg, setMsg] = useState<Msg>(null);
|
||
|
|
|
||
|
|
const setStation = (i: number, patch: Partial<StationDraft>) =>
|
||
|
|
setStations((cur) => cur.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||
|
|
|
||
|
|
const save = async (e: FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
const names = stations.map((s) => s.name.trim());
|
||
|
|
if (!name.trim()) {
|
||
|
|
setMsg({ ok: false, text: "Give the department a name." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (names.some((n) => !n)) {
|
||
|
|
setMsg({ ok: false, text: "Every station needs a name." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (new Set(names.map((n) => n.toLowerCase())).size !== names.length) {
|
||
|
|
setMsg({ ok: false, text: "Two stations have the same name." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setBusy(true);
|
||
|
|
setMsg(null);
|
||
|
|
try {
|
||
|
|
await admin9Api.patchDepartment(d.code, {
|
||
|
|
name: name.trim(),
|
||
|
|
active,
|
||
|
|
stations: stations.map((s, i) => ({ ...(s.id !== undefined ? { id: s.id } : {}), name: s.name.trim(), sort: i + 1, active: s.active })),
|
||
|
|
});
|
||
|
|
setMsg({ ok: true, text: "Department saved." });
|
||
|
|
onSaved();
|
||
|
|
} catch (err) {
|
||
|
|
setMsg({ ok: false, text: errorText(err) });
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="dept-edit-h">
|
||
|
|
<div className="sec-h">
|
||
|
|
<h3 id="dept-edit-h">
|
||
|
|
Edit {d.code} {d.name}
|
||
|
|
</h3>
|
||
|
|
<span className="grow" />
|
||
|
|
<button className="btn sm ghost" type="button" onClick={onClose}>
|
||
|
|
Close
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="frow">
|
||
|
|
<label className="f">
|
||
|
|
Department name
|
||
|
|
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
|
||
|
|
</label>
|
||
|
|
<label className="ckl">
|
||
|
|
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||
|
|
Department active
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<h3>Stations</h3>
|
||
|
|
<div className="small muted">Stations are never deleted, because station limits point at them. Clear Active to hide one.</div>
|
||
|
|
{stations.map((s, i) => (
|
||
|
|
<div className="frow" key={s.id ?? `new-${i}`}>
|
||
|
|
<label className="f">
|
||
|
|
{`Station ${i + 1} name`}
|
||
|
|
<input className="in" value={s.name} maxLength={60} onChange={(e) => setStation(i, { name: e.target.value })} />
|
||
|
|
</label>
|
||
|
|
<label className="ckl">
|
||
|
|
<input type="checkbox" checked={s.active} aria-label={`Station ${i + 1} active`} onChange={(e) => setStation(i, { active: e.target.checked })} />
|
||
|
|
Active
|
||
|
|
</label>
|
||
|
|
<div className="end">
|
||
|
|
<button
|
||
|
|
className="btn sm ghost"
|
||
|
|
type="button"
|
||
|
|
aria-label={`Move station ${i + 1} up`}
|
||
|
|
disabled={i === 0}
|
||
|
|
onClick={() => setStations((cur) => swap(cur, i, i - 1))}
|
||
|
|
>
|
||
|
|
Up
|
||
|
|
</button>{" "}
|
||
|
|
<button
|
||
|
|
className="btn sm ghost"
|
||
|
|
type="button"
|
||
|
|
aria-label={`Move station ${i + 1} down`}
|
||
|
|
disabled={i === stations.length - 1}
|
||
|
|
onClick={() => setStations((cur) => swap(cur, i, i + 1))}
|
||
|
|
>
|
||
|
|
Down
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
<div>
|
||
|
|
<button className="btn sm" type="button" onClick={() => setStations((cur) => [...cur, { name: "", active: true }])}>
|
||
|
|
Add station
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<Notice msg={msg} />
|
||
|
|
<div>
|
||
|
|
<button className="btn pri" type="submit" disabled={busy}>
|
||
|
|
Save department
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function swap<T>(list: T[], a: number, b: number): T[] {
|
||
|
|
const out = [...list];
|
||
|
|
const x = out[a];
|
||
|
|
const y = out[b];
|
||
|
|
if (x === undefined || y === undefined) return list;
|
||
|
|
out[a] = y;
|
||
|
|
out[b] = x;
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function AddDepartment({ onAdded }: { onAdded: (r: DeptAdded) => void }) {
|
||
|
|
const [code, setCode] = useState("");
|
||
|
|
const [slug, setSlug] = useState("");
|
||
|
|
const [name, setName] = useState("");
|
||
|
|
const [kind, setKind] = useState<"shop" | "support">("shop");
|
||
|
|
const [weld, setWeld] = useState(false);
|
||
|
|
const [sup, setSup] = useState(true);
|
||
|
|
const [stations, setStations] = useState("");
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [msg, setMsg] = useState<Msg>(null);
|
||
|
|
|
||
|
|
const preview = kind === "shop" && RX_CODE.test(code) && RX_SLUG.test(slug) ? SHOP_LEVELS.map((l) => `PS-${code}-${slug}-${l}`) : [];
|
||
|
|
|
||
|
|
const submit = async (e: FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!RX_CODE.test(code)) {
|
||
|
|
setMsg({ ok: false, text: "The code is 2 to 4 capital letters or digits, like 15." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!RX_SLUG.test(slug)) {
|
||
|
|
setMsg({ ok: false, text: "The short name starts with a letter and has only letters and digits, like Paint." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!name.trim()) {
|
||
|
|
setMsg({ ok: false, text: "Give the department a name." });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const list = stations
|
||
|
|
.split(/\r?\n/)
|
||
|
|
.map((s) => s.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
setBusy(true);
|
||
|
|
setMsg(null);
|
||
|
|
try {
|
||
|
|
const r = await admin9Api.addDepartment({ code, slug, name: name.trim(), kind, weld, supervisor_group: sup, stations: list });
|
||
|
|
setCode("");
|
||
|
|
setSlug("");
|
||
|
|
setName("");
|
||
|
|
setStations("");
|
||
|
|
onAdded(r);
|
||
|
|
} catch (err) {
|
||
|
|
setMsg({ ok: false, text: errorText(err) });
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<form className="panel stack" onSubmit={submit} noValidate aria-labelledby="dept-add-h">
|
||
|
|
<h3 id="dept-add-h">Add department</h3>
|
||
|
|
<div className="frow">
|
||
|
|
<label className="f">
|
||
|
|
Code
|
||
|
|
<input className="in mono" value={code} maxLength={4} onChange={(e) => setCode(e.target.value.toUpperCase())} />
|
||
|
|
</label>
|
||
|
|
<label className="f">
|
||
|
|
Short name
|
||
|
|
<input className="in" value={slug} maxLength={31} onChange={(e) => setSlug(e.target.value)} />
|
||
|
|
</label>
|
||
|
|
<label className="f">
|
||
|
|
Name
|
||
|
|
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
|
||
|
|
</label>
|
||
|
|
<label className="f">
|
||
|
|
Kind
|
||
|
|
<select className="in" aria-label="Kind" value={kind} onChange={(e) => setKind(e.target.value === "support" ? "support" : "shop")}>
|
||
|
|
<option value="shop">Shop</option>
|
||
|
|
<option value="support">Support</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<div className="frow">
|
||
|
|
<label className="ckl">
|
||
|
|
<input type="checkbox" checked={weld} onChange={(e) => setWeld(e.target.checked)} />
|
||
|
|
Weld department
|
||
|
|
</label>
|
||
|
|
<label className="ckl">
|
||
|
|
<input type="checkbox" checked={sup} onChange={(e) => setSup(e.target.checked)} />
|
||
|
|
Has a Supervisor group
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
<label className="f">
|
||
|
|
Stations, one per line
|
||
|
|
<textarea className="in" aria-label="Stations, one per line" value={stations} onChange={(e) => setStations(e.target.value)} />
|
||
|
|
</label>
|
||
|
|
{preview.length ? (
|
||
|
|
<div className="small">
|
||
|
|
IT will need to create these groups: <span className="mono">{preview.join(", ")}</span>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
<Notice msg={msg} />
|
||
|
|
<div>
|
||
|
|
<button className="btn pri" type="submit" disabled={busy}>
|
||
|
|
Add department
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DepartmentsTab() {
|
||
|
|
const [load, reload] = useLoad((s) => admin9Api.departments(s));
|
||
|
|
const [editing, setEditing] = useState<string | null>(null);
|
||
|
|
const [adding, setAdding] = useState(false);
|
||
|
|
const [added, setAdded] = useState<DeptAdded | null>(null);
|
||
|
|
|
||
|
|
const rows = load.state === "ok" ? [...load.data].sort((a, b) => a.sort - b.sort) : [];
|
||
|
|
const current = rows.find((d) => d.code === editing) ?? null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="stack">
|
||
|
|
<div className="sec-h">
|
||
|
|
<span className="small muted">Each department maps to AD groups by its code and short name. Roles come from those groups.</span>
|
||
|
|
<span className="grow" />
|
||
|
|
<button className="btn sm pri" type="button" aria-expanded={adding} onClick={() => setAdding((a) => !a)}>
|
||
|
|
{adding ? "Close the new department form" : "New department"}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{added ? (
|
||
|
|
<div className="callout" role="status" aria-label="Groups to create">
|
||
|
|
<b>
|
||
|
|
Added {added.department.code} {added.department.name}.
|
||
|
|
</b>{" "}
|
||
|
|
{added.groups_to_create.length ? (
|
||
|
|
<>
|
||
|
|
Ask IT to create these groups in the PacketStation OU:
|
||
|
|
<ul className="chips plain">
|
||
|
|
{added.groups_to_create.map((g) => (
|
||
|
|
<li key={g} className="chip static mono">
|
||
|
|
{g}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
"No new groups are needed."
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
{adding ? (
|
||
|
|
<AddDepartment
|
||
|
|
onAdded={(r) => {
|
||
|
|
setAdded(r);
|
||
|
|
setAdding(false);
|
||
|
|
reload();
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
<Problem load={load} />
|
||
|
|
{load.state === "ok" ? (
|
||
|
|
<div className="tw">
|
||
|
|
<table aria-label="Departments">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Code</th>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Kind</th>
|
||
|
|
<th>Status</th>
|
||
|
|
<th>Groups</th>
|
||
|
|
<th>Stations</th>
|
||
|
|
<th />
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{rows.map((d) => (
|
||
|
|
<tr key={d.code}>
|
||
|
|
<td className="mono">{d.code}</td>
|
||
|
|
<td>
|
||
|
|
{d.name} {d.weld ? <span className="pill st-ready">Weld</span> : null}
|
||
|
|
</td>
|
||
|
|
<td>{d.kind === "shop" ? "Shop" : "Support"}</td>
|
||
|
|
<td>{d.active ? <span className="pill st-ok">Active</span> : <span className="pill st-filed">Inactive</span>}</td>
|
||
|
|
<td>
|
||
|
|
<ul className="plain stack tight" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||
|
|
{d.groups.map((g) => (
|
||
|
|
<li key={g.name} className="small">
|
||
|
|
<span className="mono">{g.name}</span> <OuPill g={g} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</td>
|
||
|
|
<td className="small">
|
||
|
|
{d.stations.filter((s) => s.active).map((s) => s.name).join(", ") || <span className="muted">None</span>}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<button className="btn sm ghost" type="button" aria-label={`Edit department ${d.code}`} onClick={() => setEditing(d.code)}>
|
||
|
|
Edit
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
{current ? <DeptEditor key={current.code} d={current} onSaved={reload} onClose={() => setEditing(null)} /> : null}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|