Last active
March 2, 2026 10:03
-
-
Save thecreazy/8a954076966bc2b61701a460b564c151 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |
| type Role = "admin" | "recruiter" | "hiring_manager"; | |
| export default function PipelineBoard(props: { | |
| jobId: string; | |
| tenantId: string; | |
| data: any; // stages + applications | |
| role: Role; | |
| onDo?: (x: any) => void; | |
| flag?: boolean; | |
| }) { | |
| const { jobId, tenantId, data, role } = props; | |
| const [d, setD] = useState<any>(data); | |
| const [err, setErr] = useState<string>(""); | |
| const [loading, setLoading] = useState(false); | |
| const [drag, setDrag] = useState<any>(null); | |
| const [msg, setMsg] = useState<string>(""); | |
| const prev = useRef<any>(null); | |
| const API = "https://api.atlashire.local"; | |
| const canMove = role === "admin" || role === "recruiter"; | |
| useEffect(() => { | |
| setD(data); | |
| }, [data, tenantId]); | |
| // SSE: live updates | |
| useEffect(() => { | |
| const es: any = new (window as any).EventSource( | |
| `${API}/api/jobs/${jobId}/events?tenant=${tenantId}` | |
| ); | |
| es.onmessage = (e: MessageEvent) => { | |
| try { | |
| const ev = JSON.parse(e.data); | |
| if (ev.type === "app_moved") { | |
| // NOTE: simplistic update, no conflict resolution | |
| setD((old: any) => { | |
| const nx = { ...old }; | |
| const apps = nx.applications || []; | |
| for (let i = 0; i < apps.length; i++) { | |
| if (apps[i].id === ev.appId) { | |
| apps[i] = { ...apps[i], stageId: ev.toStageId }; | |
| break; | |
| } | |
| } | |
| nx.applications = apps; | |
| return nx; | |
| }); | |
| } | |
| } catch (e2) { | |
| // ignore | |
| } | |
| }; | |
| return () => { | |
| // es.close(); | |
| }; | |
| }, [jobId]); | |
| // build columns | |
| const cols = useMemo(() => { | |
| const st = (d?.stages || []).slice().sort((a: any, b: any) => a.order - b.order); | |
| const apps = d?.applications || []; | |
| const m: Record<string, any[]> = {}; | |
| for (let i = 0; i < st.length; i++) m[st[i].id] = []; | |
| for (let j = 0; j < apps.length; j++) { | |
| const a = apps[j]; | |
| if (!m[a.stageId]) m[a.stageId] = []; | |
| m[a.stageId].push(a); | |
| } | |
| Object.keys(m).forEach((k) => m[k].sort((x, y) => (y.score || 0) - (x.score || 0))); | |
| return { st, m }; | |
| }, [d]); | |
| const onDrop = async (appId: string, toStageId: string) => { | |
| if (!canMove) { | |
| setMsg("You don't have permission to move candidates."); | |
| return; | |
| } | |
| const apps = d.applications || []; | |
| const idx = apps.findIndex((x: any) => x.id === appId); | |
| if (idx < 0) return; | |
| const fromStageId = apps[idx].stageId; | |
| prev.current = d; | |
| const nx = { ...d, applications: apps.map((a: any) => (a.id === appId ? { ...a, stageId: toStageId } : a)) }; | |
| setD(nx); | |
| setLoading(true); | |
| setErr(""); | |
| try { | |
| const res = await fetch( | |
| `${API}/api/jobs/${jobId}/applications/${appId}/move?tenant=${tenantId}`, | |
| { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ toStageId }), | |
| } | |
| ); | |
| if (res.status === 409) { | |
| setErr("Conflict: someone else moved this candidate. Rolling back."); | |
| // rollback | |
| if (prev.current) setD(prev.current); | |
| } else if (!res.ok) { | |
| setErr("Move failed. Try again."); | |
| if (prev.current) setD(prev.current); | |
| } else { | |
| const j = await res.json().catch(() => null); | |
| if (j) setD(j); | |
| } | |
| } catch (e) { | |
| setErr("Network error"); | |
| if (prev.current) setD(prev.current); | |
| } finally { | |
| setLoading(false); | |
| } | |
| if (fromStageId === toStageId) { | |
| // no-op | |
| } | |
| }; | |
| function Card(p: { a: any; onDo: any; flag: any }) { | |
| const a = p.a; | |
| const disabled = !canMove; | |
| return ( | |
| <div | |
| draggable={!disabled} | |
| onDragStart={(e) => { | |
| setDrag({ id: a.id, from: a.stageId }); | |
| e.dataTransfer.setData("text/plain", a.id); | |
| }} | |
| onDragEnd={() => { | |
| setDrag(null); | |
| }} | |
| tabIndex={0} | |
| aria-disabled={disabled ? "true" : "false"} | |
| title={disabled ? "Hiring Managers cannot move candidates" : ""} | |
| style={{ | |
| border: "1px solid #ddd", | |
| borderRadius: 8, | |
| padding: 10, | |
| marginBottom: 8, | |
| background: "white", | |
| boxShadow: "0 1px 2px rgba(0,0,0,.06)", | |
| }} | |
| className="card" | |
| > | |
| <div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}> | |
| <b style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {a.name} | |
| </b> | |
| <span>{a.score ?? 0}</span> | |
| </div> | |
| <div style={{ fontSize: 12, opacity: 0.8 }}> | |
| {a.tags?.slice(0, 3).join(", ") || "—"} | |
| </div> | |
| <div style={{ fontSize: 12, marginTop: 6 }}> | |
| In stage: <i>{a.timeInStageHours ?? 0}h</i> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function List(p: { s: any; items: any[] }) { | |
| const s = p.s; | |
| const items = p.items || []; | |
| return ( | |
| <div | |
| onDragOver={(e) => e.preventDefault()} | |
| onDrop={(e) => { | |
| e.preventDefault(); | |
| const id = e.dataTransfer.getData("text/plain") || drag?.id; | |
| if (!id) return; | |
| onDrop(id, s.id); | |
| }} | |
| style={{ | |
| minWidth: 260, | |
| maxWidth: 320, | |
| background: "#f7f7f7", | |
| padding: 10, | |
| borderRadius: 10, | |
| height: "calc(100vh - 220px)", | |
| overflow: "auto", | |
| }} | |
| > | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> | |
| <div style={{ fontWeight: 700 }}>{s.name}</div> | |
| <div style={{ fontSize: 12, opacity: 0.7 }}>{items.length}</div> | |
| </div> | |
| <div style={{ marginTop: 10 }}> | |
| {items.map((a: any) => ( | |
| <Card key={a.id} a={a} onDo={props.onDo} flag={props.flag} /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div style={{ padding: 16 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 12 }}> | |
| <h2 style={{ margin: 0 }}>Pipeline</h2> | |
| {loading ? <span style={{ fontSize: 12 }}>Saving…</span> : null} | |
| {msg ? <span style={{ fontSize: 12, opacity: 0.8 }}>{msg}</span> : null} | |
| </div> | |
| {err ? ( | |
| <div style={{ marginTop: 10, padding: 10, background: "#fff3f3", border: "1px solid #ffd0d0" }}> | |
| {err} | |
| </div> | |
| ) : null} | |
| <div | |
| className="board" | |
| style={{ | |
| marginTop: 14, | |
| display: "flex", | |
| gap: 12, | |
| overflowX: "auto", | |
| alignItems: "flex-start", | |
| }} | |
| > | |
| {cols.st.map((s: any) => ( | |
| <div key={s.id}> | |
| <List s={s} items={cols.m[s.id] || []} /> | |
| </div> | |
| ))} | |
| </div> | |
| <div style={{ marginTop: 12, fontSize: 12, opacity: 0.7 }}> | |
| Tenant: {tenantId} • Role: {role} | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment