aboutsummaryrefslogtreecommitdiff
path: root/174bg/manager/src/components
diff options
context:
space:
mode:
authorAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-25 17:27:38 +0100
committerAlex Pooley (@zuedev) <zuedev@gmail.com>2026-07-25 17:27:38 +0100
commita85a54651070765d6254287369835d747be1c276 (patch)
tree6598d2e36bf5a3770c9450e1acceefcfcb092842 /174bg/manager/src/components
parent0d001f0094f2784033be52fa2a28102c271ffb1e (diff)
downloadunnamed-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.jsx73
-rw-r--r--174bg/manager/src/components/OnCallSchedule.jsx186
-rw-r--r--174bg/manager/src/components/RequiredInfo.jsx66
-rw-r--r--174bg/manager/src/components/RolePreferences.jsx171
-rw-r--r--174bg/manager/src/components/Welcome.jsx14
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>
+ );
+}