Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion components/board/BoardTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const memberFields = (teams: readonly string[]): FieldDef[] => [
{ key: "role", label: "Role", placeholder: "Data Engineer" },
{ key: "team", label: "Team", type: "select", options: ["Executive", ...teams].map((t) => ({ value: t, label: t })), pair: "t" },
{ key: "alsoTeam", label: "Also leads (optional)", type: "select", options: [{ value: "", label: "None" }, ...teams.map((t) => ({ value: t, label: t }))], pair: "t" },
{ key: "tier", label: "Top of chart", type: "select", options: [{ value: "", label: "No" }, { value: "0", label: "President row" }, { value: "1", label: "Executive row" }] },
{ key: "tier", label: "Top of chart", type: "select", options: [{ value: "", label: "No" }, { value: "0", label: "Top row" }, { value: "1", label: "Second row" }] },
{ key: "isLead", label: "Team lead", type: "checkbox" },
{ key: "email", label: "Email", type: "email", pair: "c" },
{ key: "phone", label: "Phone", type: "tel", pair: "c" },
Expand All @@ -27,6 +27,7 @@ export function BoardTab() {
const { chapter, round, data, user, go, setFocus, refresh } = useBoard();
const TEAM_ORDER = data.settings.teams;
const [editingTeams, setEditingTeams] = useState(false);
const [replacing, setReplacing] = useState(false);
const ALL_TEAMS = ["All", "Executive", ...TEAM_ORDER];
const MEMBER_FIELDS = memberFields(TEAM_ORDER);
const admin = user.role === "admin";
Expand Down Expand Up @@ -127,8 +128,23 @@ export function BoardTab() {
Edit teams
</button>
)}
{admin && (
<button type="button" className="chip" onClick={() => setReplacing(true)}>
Replace the member list
</button>
)}
</div>
{editingTeams && <TeamsEditor onDone={() => setEditingTeams(false)} />}
{replacing && (
<ImportMembers
replacing={{ count: people.length, onCancel: () => setReplacing(false) }}
onLoaded={(n) => {
setReplacing(false);
setLoaded(n);
refresh();
}}
/>
)}

{editing === "new" && (
<RowEditor title="Add a member" table="members" fields={MEMBER_FIELDS} initial={{ name: "", role: "", team: TEAM_ORDER[0], alsoTeam: "", tier: "", isLead: false, email: "", phone: "", hubAdmin: false }} onClose={() => setEditing(null)} />
Expand Down
75 changes: 62 additions & 13 deletions components/board/ImportMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ function readText(file: File): Promise<string> {
}

// Posts the list. Returns an error message, or null when it saved.
async function send(members: unknown[]): Promise<string | null> {
async function send(members: unknown[], replace: boolean): Promise<string | null> {
let res: Response;
try {
res = await fetch(chapterUrl("/api/board/import-members"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ members }),
body: JSON.stringify({ members, replace }),
});
} catch {
return "Couldn't reach the server. Check your connection and try again.";
Expand All @@ -41,14 +41,26 @@ async function send(members: unknown[]): Promise<string | null> {
// Admin-only: loads the member list from board-members.local.json. It isn't
// in the code because it has phone numbers. `onLoaded` runs after a
// successful import, so the Board tab can say so once this box is gone.
// With `replacing`, it swaps out the members already loaded, after a confirm.
export function ImportMembers({
onLoaded,
replacing,
}: {
onLoaded: (count: number) => void;
replacing?: { count: number; onCancel: () => void };
}) {
const { chapter } = useBoard();
const fileName = chapter.id === "berkeley" ? "board-members.local.json" : `board-members-${chapter.id}.local.json`;
const [status, setStatus] = useState<Status | null>(null);
const [pending, setPending] = useState<unknown[] | null>(null);

async function load(members: unknown[]) {
setStatus({ kind: "busy", text: `Loading ${members.length} members…` });
const err = await send(members, !!replacing);
if (err) return setStatus({ kind: "err", text: err });
setStatus({ kind: "ok", text: `Loaded ${members.length} members.` });
onLoaded(members.length);
}

async function onFile(e: ChangeEvent<HTMLInputElement>) {
const input = e.currentTarget;
Expand All @@ -75,12 +87,11 @@ export function ImportMembers({
text: `${file.name} doesn't have a member list in it. Choose ${fileName}.`,
});
}
setStatus({ kind: "busy", text: `Loading ${members.length} members…` });
const err = await send(members);
if (err) return setStatus({ kind: "err", text: err });
const count = members.length;
setStatus({ kind: "ok", text: `Loaded ${count} members.` });
onLoaded(count);
if (replacing) {
setStatus(null);
return setPending(members);
}
await load(members);
} catch {
setStatus({
kind: "err",
Expand All @@ -94,11 +105,41 @@ export function ImportMembers({
const busy = status?.kind === "busy";
return (
<div className="note">
<p>
<strong>The member list isn&apos;t loaded yet.</strong> Choose the member
list file, <strong>{fileName}</strong>. It has everyone&apos;s names,
roles, emails and phone numbers.
</p>
{replacing ? (
<p>
<strong>Replace the member list.</strong> Choose{" "}
<strong>{fileName}</strong>. It replaces all {replacing.count} members,
including edits made on this tab. The old list stays in the Changes
tab, where it can be restored.
</p>
) : (
<p>
<strong>The member list isn&apos;t loaded yet.</strong> Choose the member
list file, <strong>{fileName}</strong>. It has everyone&apos;s names,
roles, emails and phone numbers.
</p>
)}
{pending ? (
<div className="admin-row confirm">
<strong>
Replace {replacing?.count} members with the {pending.length} in this file?
</strong>
<button
className="btn"
type="button"
disabled={busy}
onClick={async () => {
await load(pending);
setPending(null);
}}
>
{busy ? "Replacing…" : "Yes, replace"}
</button>
<button className="btn btn-line" type="button" disabled={busy} onClick={() => setPending(null)}>
Choose another file
</button>
</div>
) : (
<label htmlFor="import-members" className="check">
{/* No `accept` filter: phones often label .json files as plain data and grey them out. */}
<input
Expand All @@ -108,6 +149,14 @@ export function ImportMembers({
onChange={onFile}
/>
</label>
)}
{replacing && !pending && (
<p>
<button className="linkbtn" type="button" disabled={busy} onClick={replacing.onCancel}>
Cancel
</button>
</p>
)}
{status && (
<p
className={`msg ${status.kind === "busy" ? "" : status.kind}`}
Expand Down
2 changes: 1 addition & 1 deletion lib/board/chapter-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const CHAPTER_SERVER: Record<ChapterId, ChapterServer> = {
],
cc: [{ email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }],
techTeams: ["App Dev", "Data"],
techTo: [{ email: "rizwaanbana@g.ucla.edu", name: "Rizwaan Bana", short: "Rizwaan" }],
techTo: [{ email: "rizwaanbana@ucla.edu", name: "Rizwaan Bana", short: "Rizwaan" }],
},
},
};
Expand Down
17 changes: 17 additions & 0 deletions lib/board/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ async function setup() {
await seedUcla(sql, empty);
await seedSettings(sql);
await markHubAdmins(sql);
await renameEvanceline(sql);
}

// UCLA's Design Lead is Evanceline Tang; the starting data called her
// Celine. Renames her in UCLA's assignments, submissions and meeting notes,
// once, so her tasks match her member entry.
async function renameEvanceline(sql: ReturnType<typeof db>) {
await sql.begin(async (tx) => {
const [first] = await tx`INSERT INTO board_changes (name) VALUES ('2026-10-15-ucla-evanceline')
ON CONFLICT DO NOTHING RETURNING name`;
if (!first) return;
await tx`UPDATE board_assignments SET person = 'Evanceline' WHERE chapter = 'ucla' AND person = 'Celine'`;
await tx`UPDATE board_submissions SET person = 'Evanceline' WHERE chapter = 'ucla' AND person = 'Celine'`;
const word = String.raw`\mCeline\M`;
await tx`UPDATE board_meetings SET absent = regexp_replace(absent, ${word}, 'Evanceline', 'g')
WHERE chapter = 'ucla' AND absent ~ ${word}`;
});
}

// Ticks "Hub admin" for each chapter's starting admins, once.
Expand Down
8 changes: 4 additions & 4 deletions lib/board/starter-ucla.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
]
],
[
"Celine",
"Evanceline",
"Design Lead",
[
"Start week one of your plan",
Expand Down Expand Up @@ -127,7 +127,7 @@
"Start week one, pick your team"
],
[
"Celine",
"Evanceline",
"Design Lead",
"Start week one, pick your team"
],
Expand Down Expand Up @@ -253,11 +253,11 @@
"meetings": [
{
"date": "2026-10-11",
"absent": "Celine, Rizwaan, Fernando, Ayat"
"absent": "Evanceline, Rizwaan, Fernando, Ayat"
},
{
"date": "2026-10-04",
"absent": "Celine and Ray (both excused)"
"absent": "Evanceline and Ray (both excused)"
},
{
"date": "2026-09-27",
Expand Down