diff options
| author | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-25 17:27:38 +0100 |
|---|---|---|
| committer | Alex Pooley (@zuedev) <zuedev@gmail.com> | 2026-07-25 17:27:38 +0100 |
| commit | a85a54651070765d6254287369835d747be1c276 (patch) | |
| tree | 6598d2e36bf5a3770c9450e1acceefcfcb092842 /174bg/manager/src/components | |
| parent | 0d001f0094f2784033be52fa2a28102c271ffb1e (diff) | |
| download | unnamed-group-a85a54651070765d6254287369835d747be1c276.tar unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.gz unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.bz2 unnamed-group-a85a54651070765d6254287369835d747be1c276.tar.xz unnamed-group-a85a54651070765d6254287369835d747be1c276.zip | |
manager is now using react
Diffstat (limited to '174bg/manager/src/components')
| -rw-r--r-- | 174bg/manager/src/components/Ledger.jsx | 73 | ||||
| -rw-r--r-- | 174bg/manager/src/components/OnCallSchedule.jsx | 186 | ||||
| -rw-r--r-- | 174bg/manager/src/components/RequiredInfo.jsx | 66 | ||||
| -rw-r--r-- | 174bg/manager/src/components/RolePreferences.jsx | 171 | ||||
| -rw-r--r-- | 174bg/manager/src/components/Welcome.jsx | 14 |
5 files changed, 510 insertions, 0 deletions
diff --git a/174bg/manager/src/components/Ledger.jsx b/174bg/manager/src/components/Ledger.jsx new file mode 100644 index 0000000..247b343 --- /dev/null +++ b/174bg/manager/src/components/Ledger.jsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import { pb } from "../lib/pocketbase"; + +function memberLabel(record, id) { + const m = record?.expand?.[id]; + return m ? m.RSI_Handle || m.name || m.id : (record?.[id] ?? "—"); +} + +export default function Ledger() { + const [records, setRecords] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + pb + .collection("ledger") + .getFullList({ expand: "sender,recipient", sort: "-created", requestKey: null }) + .then((recs) => { + if (!cancelled) setRecords(recs); + }) + .catch((err) => { + console.error("Failed to load ledger:", err); + if (!cancelled) setError(err); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + <div id="ledger"> + <div> + <b>Ledger</b> + </div> + {error && ( + <p className="empty" style={{ color: "red" }}> + Failed to load ledger: {error?.message ?? String(error)} + </p> + )} + {!error && records && records.length === 0 && ( + <p className="empty">No ledger entries.</p> + )} + {!error && records && records.length > 0 && ( + <table> + <thead> + <tr> + <th>Date</th> + <th>Sender</th> + <th>Recipient</th> + <th>UEC</th> + <th>Note</th> + </tr> + </thead> + <tbody> + {records.map((rec) => ( + <tr key={rec.id}> + <td data-label="Date"> + {rec.created ? new Date(rec.created).toLocaleString() : "—"} + </td> + <td data-label="Sender">{memberLabel(rec, "sender")}</td> + <td data-label="Recipient">{memberLabel(rec, "recipient")}</td> + <td className="uec" data-label="UEC"> + {Number(rec.uec ?? 0).toLocaleString()} + </td> + <td data-label="Note">{rec.note ?? ""}</td> + </tr> + ))} + </tbody> + </table> + )} + </div> + ); +} diff --git a/174bg/manager/src/components/OnCallSchedule.jsx b/174bg/manager/src/components/OnCallSchedule.jsx new file mode 100644 index 0000000..725e5c8 --- /dev/null +++ b/174bg/manager/src/components/OnCallSchedule.jsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from "react"; +import { pb } from "../lib/pocketbase"; +import { + DAYS_OF_WEEK, + formatUtcOffset, + getLocalOnCallSchedule, + listTimeZones, + localScheduleToUtc, +} from "../lib/oncallTime"; + +export default function OnCallSchedule({ record, onUpdate }) { + const [editing, setEditing] = useState(false); + const [timeZone, setTimeZone] = useState(null); + const [schedule, setSchedule] = useState({}); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState({ text: "", error: false }); + + const display = useMemo(() => getLocalOnCallSchedule(record), [record]); + const zones = useMemo(() => listTimeZones(), []); + + function startEdit() { + const { timeZone: tz, schedule: sched } = getLocalOnCallSchedule(record); + setTimeZone(tz); + setSchedule(sched); + setEditing(true); + setStatus({ text: "", error: false }); + } + + function cancelEdit() { + setEditing(false); + } + + function toggleDay(day, available) { + setSchedule((prev) => { + const next = { ...prev }; + if (available) { + next[day] = { + available: true, + start: prev[day]?.start ?? "", + end: prev[day]?.end ?? "", + }; + } else { + delete next[day]; + } + return next; + }); + } + + function setDayTime(day, key, value) { + setSchedule((prev) => ({ + ...prev, + [day]: { ...prev[day], [key]: value }, + })); + } + + async function save() { + setSaving(true); + try { + const utcSchedule = localScheduleToUtc(schedule, timeZone); + const payload = { timezone: timeZone, ...utcSchedule }; + const updated = await pb.collection("members").update(record.id, { + onCallSchedule: payload, + }); + onUpdate(updated); + setEditing(false); + setStatus({ text: "", error: false }); + } catch (err) { + const msg = err?.response + ? JSON.stringify(err.response) + : (err?.message ?? String(err)); + setStatus({ text: `Save failed: ${msg}`, error: true }); + } finally { + setSaving(false); + } + } + + const shownTz = editing ? timeZone : display.timeZone; + const shownSchedule = editing ? schedule : display.schedule; + const tzOptions = zones.includes(shownTz) ? zones : [shownTz, ...zones]; + + return ( + <div id="oncall"> + <div> + <b>On-Call Schedule</b>{" "} + {!editing && ( + <button type="button" onClick={startEdit}> + ✏️ Edit + </button> + )} + {editing && ( + <> + <button type="button" disabled={saving} onClick={save}> + 💾 Save + </button>{" "} + <button type="button" disabled={saving} onClick={cancelEdit}> + ❌ Cancel + </button> + </> + )} + </div> + <p style={{ margin: "0.25rem 0 0.75rem", fontSize: "0.85rem", color: "var(--text-dim)" }}> + Let the group know which days and hours you're usually free to join operations. + </p> + <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem" }}> + <b>Timezone:</b> + {editing ? ( + <select value={timeZone} onChange={(e) => setTimeZone(e.target.value)}> + {tzOptions.map((tz) => ( + <option key={tz} value={tz}> + {tz} ({formatUtcOffset(tz)}) + </option> + ))} + </select> + ) : ( + <span> + {shownTz} ({formatUtcOffset(shownTz)}) + </span> + )} + </div> + <table> + <thead> + <tr> + <th>Day</th> + <th>Free</th> + <th>From</th> + <th>To</th> + </tr> + </thead> + <tbody> + {DAYS_OF_WEEK.map((day) => { + const entry = shownSchedule[day] ?? {}; + return ( + <tr key={day}> + <td>{day}</td> + <td> + {editing ? ( + <input + type="checkbox" + checked={!!entry.available} + onChange={(e) => toggleDay(day, e.target.checked)} + /> + ) : entry.available ? ( + "✅" + ) : ( + "❌" + )} + </td> + <td> + {editing ? ( + <input + type="time" + value={entry.start ?? ""} + disabled={!entry.available} + onChange={(e) => setDayTime(day, "start", e.target.value)} + /> + ) : entry.available && entry.start ? ( + entry.start + ) : ( + "—" + )} + </td> + <td> + {editing ? ( + <input + type="time" + value={entry.end ?? ""} + disabled={!entry.available} + onChange={(e) => setDayTime(day, "end", e.target.value)} + /> + ) : entry.available && entry.end ? ( + entry.end + ) : ( + "—" + )} + </td> + </tr> + ); + })} + </tbody> + </table> + <p style={{ margin: "0.5rem 0 0", fontSize: "0.85rem", color: status.error ? "red" : "" }}> + {status.text} + </p> + </div> + ); +} diff --git a/174bg/manager/src/components/RequiredInfo.jsx b/174bg/manager/src/components/RequiredInfo.jsx new file mode 100644 index 0000000..67c6799 --- /dev/null +++ b/174bg/manager/src/components/RequiredInfo.jsx @@ -0,0 +1,66 @@ +import { RANK_NAMES } from "../lib/roles"; + +const REQUIRED_INFO_FIELDS = [ + { field: "id", label: "174BG ID" }, + { field: "UEE_Citizen_Record_ID", label: "UEE Citizen Record ID" }, + { field: "RSI_Display_Name", label: "RSI Display Name" }, + { field: "RSI_Handle", label: "RSI Handle" }, + { field: "joined_rsi_org", label: "Joined RSI Organisation" }, + { field: "email", label: "Email" }, + { field: "Branch", label: "Branch" }, + { field: "Rank_Number", label: "Rank Number" }, +]; + +export default function RequiredInfo({ record }) { + let anyMissing = false; + + const rows = REQUIRED_INFO_FIELDS.map(({ field, label }) => { + const value = record?.[field]; + // Treat only null/undefined/empty-string as missing so legitimately + // falsy values (e.g. Rank_Number 0, joined_rsi_org false) still show. + const missing = value == null || value === ""; + if (missing) anyMissing = true; + return { field, label, value, missing }; + }); + + const branch = record?.Branch; + const rankNumber = record?.Rank_Number; + const rankName = RANK_NAMES[branch]?.[rankNumber]; + if (!rankName) anyMissing = true; + + const staffRolesRaw = record?.StaffRoles; + const staffList = Array.isArray(staffRolesRaw) + ? staffRolesRaw + : staffRolesRaw + ? [staffRolesRaw] + : []; + + return ( + <div id="required-info"> + <h2>Required Information</h2> + {rows.map(({ field, label, value, missing }) => ( + <div key={field}> + <b>{label}:</b>{" "} + <span style={{ color: missing ? "red" : "" }}> + {missing ? "MISSING" : value} + </span> + </div> + ))} + <div> + <b>Rank Name:</b>{" "} + <span style={{ color: rankName ? "" : "red" }}> + {rankName ?? "MISSING"} + </span> + </div> + <div> + <b>Staff Roles:</b>{" "} + <span>{staffList.length > 0 ? staffList.join(", ") : "none"}</span> + </div> + {anyMissing && ( + <div id="profile-warning"> + ⚠️ Your profile is incomplete. Message an officer ASAP! + </div> + )} + </div> + ); +} diff --git a/174bg/manager/src/components/RolePreferences.jsx b/174bg/manager/src/components/RolePreferences.jsx new file mode 100644 index 0000000..ab3f282 --- /dev/null +++ b/174bg/manager/src/components/RolePreferences.jsx @@ -0,0 +1,171 @@ +import { Fragment, useState } from "react"; +import { pb } from "../lib/pocketbase"; +import { ROLES } from "../lib/roles"; +import { getOtherJsonData } from "../lib/otherJsonData"; + +export default function RolePreferences({ record, onUpdate }) { + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState([]); + const [favourite, setFavourite] = useState(null); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState({ text: "", error: false }); + + const savedPrefs = record?.RolePreferencesSelect ?? []; + const savedFavourite = getOtherJsonData(record).FavouriteRole ?? null; + + function startEdit() { + setSelected(savedPrefs); + setFavourite(savedFavourite); + setEditing(true); + setStatus({ text: "", error: false }); + } + + function cancelEdit() { + setEditing(false); + } + + function toggleRole(value) { + setSelected((prev) => + prev.includes(value) + ? prev.filter((v) => v !== value) + : [...prev, value], + ); + } + + function toggleFavourite(value) { + setFavourite((prev) => (prev === value ? null : value)); + } + + async function save() { + setSaving(true); + try { + const fav = favourite && selected.includes(favourite) ? favourite : null; + const other = getOtherJsonData(record); + if (fav) other.FavouriteRole = fav; + else delete other.FavouriteRole; + + const updated = await pb.collection("members").update(record.id, { + RolePreferencesSelect: selected, + OtherJsonData: other, + }); + onUpdate(updated); + setEditing(false); + setStatus({ text: "", error: false }); + } catch (err) { + const msg = err?.response + ? JSON.stringify(err.response) + : (err?.message ?? String(err)); + setStatus({ text: `Save failed: ${msg}`, error: true }); + } finally { + setSaving(false); + } + } + + const displayPrefs = editing ? selected : savedPrefs; + const displayFavourite = editing ? favourite : savedFavourite; + + let lastBranch = null; + + return ( + <div id="preferences"> + <div> + <b>Role Preferences</b>{" "} + {!editing && ( + <button type="button" onClick={startEdit}> + ✏️ Edit + </button> + )} + {editing && ( + <> + <button type="button" disabled={saving} onClick={save}> + 💾 Save + </button>{" "} + <button type="button" disabled={saving} onClick={cancelEdit}> + ❌ Cancel + </button> + </> + )} + </div> + <table> + <thead> + <tr> + <th>Role</th> + <th>✅</th> + <th>⭐</th> + </tr> + </thead> + <tbody> + {ROLES.map((role) => { + const branchHeader = role.branch !== lastBranch; + lastBranch = role.branch; + return ( + <Fragment key={role.value}> + {branchHeader && ( + <tr data-separator="true"> + <td + colSpan={3} + style={{ + fontWeight: 600, + paddingTop: "0.75rem", + paddingBottom: "0.25rem", + borderBottom: "none", + opacity: 0.6, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", + }} + > + {role.branch ?? ""} + </td> + </tr> + )} + <tr> + <td> + {role.link ? ( + <a href={role.link} target="_blank" rel="noopener noreferrer"> + {role.text} + </a> + ) : ( + role.text + )} + </td> + <td> + {editing ? ( + <input + type="checkbox" + checked={selected.includes(role.value)} + onChange={() => toggleRole(role.value)} + /> + ) : displayPrefs.includes(role.value) ? ( + "✅" + ) : ( + "❌" + )} + </td> + <td> + {editing ? ( + <input + type="radio" + name="favourite-role" + checked={favourite === role.value} + onChange={() => {}} + onClick={() => toggleFavourite(role.value)} + /> + ) : displayFavourite === role.value ? ( + "⭐" + ) : ( + "" + )} + </td> + </tr> + </Fragment> + ); + })} + </tbody> + </table> + <p style={{ margin: 0, fontSize: "0.85rem", color: status.error ? "red" : "" }}> + {status.text} + </p> + </div> + ); +} diff --git a/174bg/manager/src/components/Welcome.jsx b/174bg/manager/src/components/Welcome.jsx new file mode 100644 index 0000000..da771cf --- /dev/null +++ b/174bg/manager/src/components/Welcome.jsx @@ -0,0 +1,14 @@ +export default function Welcome({ record }) { + const name = record?.RSI_Handle || record?.name || "Pilot"; + const avatarFile = record?.avatar; + const avatarUrl = avatarFile + ? `https://db.174bg.net/api/files/${record.collectionId}/${record.id}/${avatarFile}` + : null; + + return ( + <div id="welcome"> + {avatarUrl && <img id="welcome-avatar" src={avatarUrl} alt={name} />} + <span id="welcome-text">Welcome back, {name}!</span> + </div> + ); +} |
