447 lines
20 KiB
JavaScript
447 lines
20 KiB
JavaScript
// seatmap.jsx — top-down room layout with tables, chairs, and tutor notes panel
|
||
|
||
// SeatMap — pure visual; takes assignments + selectedStudent + variant + onSeatClick
|
||
function SeatMap({
|
||
assignments = SEAT_ASSIGN,
|
||
selectedStudent,
|
||
onSeatClick,
|
||
hoveredSeat,
|
||
setHoveredSeat,
|
||
variant = "tutor", // "tutor" | "student" | "student-self"
|
||
ownSeat, // for student view: which seat is "mine"
|
||
scale = 1,
|
||
}) {
|
||
const W = ROOM.width, H = ROOM.height;
|
||
const studentBy = (id) => STUDENTS.find((s) => s.id === id);
|
||
|
||
return (
|
||
<div style={{
|
||
position: "relative",
|
||
width: W * scale, height: H * scale,
|
||
background: "#f7f1e3",
|
||
border: "1px solid var(--rule)",
|
||
borderRadius: 4,
|
||
overflow: "hidden",
|
||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.02), 0 1px 0 rgba(0,0,0,0.03)",
|
||
}}>
|
||
{/* Inner ruled grid — like graph paper */}
|
||
<div style={{
|
||
position: "absolute", inset: 0,
|
||
backgroundImage:
|
||
"linear-gradient(to right, rgba(110,90,60,0.05) 1px, transparent 1px), " +
|
||
"linear-gradient(to bottom, rgba(110,90,60,0.05) 1px, transparent 1px)",
|
||
backgroundSize: `${24*scale}px ${24*scale}px`,
|
||
}} />
|
||
|
||
<div style={{
|
||
position: "absolute", inset: 0,
|
||
transform: `scale(${scale})`,
|
||
transformOrigin: "top left",
|
||
width: W, height: H,
|
||
}}>
|
||
{/* Walls */}
|
||
<div style={{
|
||
position: "absolute",
|
||
left: ROOM.walls.x, top: ROOM.walls.y,
|
||
width: ROOM.walls.w, height: ROOM.walls.h,
|
||
border: "2px solid var(--ink-2)",
|
||
borderRadius: 2,
|
||
}} />
|
||
|
||
{/* Window */}
|
||
<div style={{
|
||
position: "absolute",
|
||
left: ROOM.window.x - 2, top: ROOM.window.y,
|
||
width: ROOM.window.w + 4, height: ROOM.window.h,
|
||
background: "#dfeaf0",
|
||
borderTop: "2px solid var(--ink-2)",
|
||
borderBottom: "2px solid var(--ink-2)",
|
||
}}>
|
||
<div style={{ position: "absolute", left: 1, top: "50%", width: ROOM.window.w + 2, height: 2, background: "var(--ink-2)" }} />
|
||
</div>
|
||
<div style={{
|
||
position: "absolute", left: -8, top: ROOM.window.y + ROOM.window.h / 2 - 6,
|
||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||
transform: "rotate(-90deg)", transformOrigin: "left top",
|
||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||
}}>Fenster</div>
|
||
|
||
{/* Door — gap with arc */}
|
||
<div style={{
|
||
position: "absolute",
|
||
left: ROOM.door.x, top: ROOM.door.y - 2,
|
||
width: ROOM.door.w, height: 4,
|
||
background: "#f7f1e3",
|
||
}} />
|
||
<svg style={{ position: "absolute", left: ROOM.door.x, top: ROOM.door.y - 36, pointerEvents: "none" }}
|
||
width={ROOM.door.w + 10} height="40">
|
||
<path d={`M 2 38 Q 2 2 ${ROOM.door.w} 2`} stroke="var(--ink-3)" strokeWidth="1" strokeDasharray="2 2" fill="none"/>
|
||
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" strokeWidth="1.5"/>
|
||
</svg>
|
||
<div style={{
|
||
position: "absolute", left: ROOM.door.x + 6, top: ROOM.door.y + 6,
|
||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||
}}>Tür</div>
|
||
|
||
{/* Beamer */}
|
||
<div style={{
|
||
position: "absolute",
|
||
left: ROOM.beamer.x, top: ROOM.beamer.y,
|
||
width: ROOM.beamer.w, height: ROOM.beamer.h,
|
||
background: "var(--ink-2)", borderRadius: 1,
|
||
}} />
|
||
<div style={{
|
||
position: "absolute", left: ROOM.beamer.x + ROOM.beamer.w + 6, top: ROOM.beamer.y,
|
||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||
letterSpacing: "0.1em", textTransform: "uppercase",
|
||
}}>Beamer</div>
|
||
|
||
{/* Podium */}
|
||
<div style={{
|
||
position: "absolute",
|
||
left: ROOM.podium.x, top: ROOM.podium.y,
|
||
width: ROOM.podium.w, height: ROOM.podium.h,
|
||
border: "1.5px solid var(--ink-2)",
|
||
background: "#efe6d2",
|
||
borderRadius: 2,
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-3)",
|
||
letterSpacing: "0.15em", textTransform: "uppercase",
|
||
}}>
|
||
Pult · Tutor:in
|
||
</div>
|
||
|
||
{/* Tables */}
|
||
{ROOM.tables.map((t) => (
|
||
<div key={t.id} style={{
|
||
position: "absolute",
|
||
left: t.x, top: t.y, width: t.w, height: t.h,
|
||
background: "#e8dec5",
|
||
border: "1.5px solid var(--ink-2)",
|
||
borderRadius: 3,
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--serif)", fontSize: 22, color: "rgba(31,27,22,0.35)",
|
||
fontStyle: "italic",
|
||
}}>
|
||
{t.label}
|
||
</div>
|
||
))}
|
||
|
||
{/* Seats */}
|
||
{SEATS.map((seat) => {
|
||
const sid = assignments[seat.id];
|
||
const student = sid ? studentBy(sid) : null;
|
||
const isSelected = selectedStudent && sid === selectedStudent;
|
||
const isHover = hoveredSeat === seat.id;
|
||
const isOwn = ownSeat === seat.id;
|
||
|
||
let bg, border, label, labelColor;
|
||
if (variant === "tutor") {
|
||
if (student) {
|
||
bg = isSelected ? "var(--ink)" : (isHover ? "#e0d4b6" : "#fbf7ee");
|
||
border = isSelected ? "var(--ink)" : "var(--ink-2)";
|
||
label = student.initials;
|
||
labelColor = isSelected ? "#f7eedc" : "var(--ink)";
|
||
} else {
|
||
bg = "#f7f1e3";
|
||
border = "var(--ink-4)";
|
||
label = "";
|
||
labelColor = "var(--ink-4)";
|
||
}
|
||
} else if (variant === "student") {
|
||
// student picking a seat
|
||
if (student) {
|
||
bg = "#d6cdb5"; // grey occupied
|
||
border = "var(--ink-4)";
|
||
label = "";
|
||
} else if (isOwn) {
|
||
bg = "var(--accent)";
|
||
border = "var(--accent)";
|
||
label = "";
|
||
} else {
|
||
bg = "#fbf7ee";
|
||
border = "var(--ink-2)";
|
||
label = "";
|
||
}
|
||
} else if (variant === "student-self") {
|
||
// read-only own seat
|
||
if (isOwn) {
|
||
bg = "var(--accent)";
|
||
border = "var(--accent)";
|
||
} else if (student) {
|
||
bg = "#d6cdb5";
|
||
border = "var(--ink-4)";
|
||
} else {
|
||
bg = "#fbf7ee";
|
||
border = "var(--ink-3)";
|
||
}
|
||
label = "";
|
||
}
|
||
|
||
return (
|
||
<button key={seat.id}
|
||
onClick={() => onSeatClick && onSeatClick(seat)}
|
||
onMouseEnter={() => setHoveredSeat && setHoveredSeat(seat.id)}
|
||
onMouseLeave={() => setHoveredSeat && setHoveredSeat(null)}
|
||
style={{
|
||
position: "absolute",
|
||
left: seat.x - 18, top: seat.y - 18,
|
||
width: 36, height: 36, borderRadius: "50%",
|
||
background: bg, border: `1.5px solid ${border}`,
|
||
cursor: onSeatClick ? "pointer" : "default",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--sans)", fontWeight: 600, fontSize: 11,
|
||
color: labelColor, padding: 0,
|
||
boxShadow: isSelected ? "0 0 0 3px rgba(241,211,106,0.6)" : "none",
|
||
transition: "background 120ms, border-color 120ms",
|
||
}}
|
||
title={student ? student.name : "frei"}>
|
||
{label}
|
||
{isOwn && variant !== "tutor" && (
|
||
<span style={{ color: "#f7eedc", fontSize: 14 }}>★</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
{/* Compass */}
|
||
<div style={{
|
||
position: "absolute", right: 18, bottom: 14,
|
||
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
|
||
letterSpacing: "0.15em",
|
||
}}>
|
||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||
<span>N</span>
|
||
<svg width="14" height="22"><path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" strokeWidth="1" fill="none"/></svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// Tutor live view — seat map + roster + notes panel
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
function TutorLiveView({ variant = "split" }) {
|
||
// variant: "split" (map + side panel) | "stacked" (notes below) | "compact"
|
||
const [selected, setSelected] = React.useState(3);
|
||
const [hovered, setHovered] = React.useState(null);
|
||
const [notes, setNotes] = React.useState(NOTES);
|
||
|
||
const presentIds = Object.values(SEAT_ASSIGN);
|
||
const presentSet = new Set(presentIds);
|
||
const present = STUDENTS.filter((s) => presentSet.has(s.id));
|
||
const absent = STUDENTS.filter((s) => !presentSet.has(s.id));
|
||
const sel = STUDENTS.find((s) => s.id === selected);
|
||
|
||
const seatOf = (sid) => Object.entries(SEAT_ASSIGN).find(([, v]) => v === sid)?.[0];
|
||
|
||
return (
|
||
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||
{/* Header bar */}
|
||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
|
||
<div>
|
||
<div className="eyebrow" style={{ marginBottom: 4 }}>Tutor:innen-Ansicht · Live</div>
|
||
<div className="serif" style={{ fontSize: 30, fontWeight: 500, letterSpacing: "-0.01em" }}>
|
||
Woche 04 <span style={{ color: "var(--ink-4)" }}>·</span> <span className="marker">Donnerstag, 30. April 2026</span>
|
||
</div>
|
||
<div className="small" style={{ marginTop: 6, display: "flex", gap: 14, alignItems: "center" }}>
|
||
<span>{COURSE.name}, {COURSE.semester}</span>
|
||
<span style={{ color: "var(--rule)" }}>·</span>
|
||
<span>{ROOM.name}</span>
|
||
<span style={{ color: "var(--rule)" }}>·</span>
|
||
<span>14:00 – 15:00 Uhr</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<div style={{ textAlign: "right" }}>
|
||
<div className="eyebrow">Check-in Code</div>
|
||
<div className="mono" style={{ fontSize: 22, letterSpacing: "0.18em", fontWeight: 600 }}>K7QJ-MX2P</div>
|
||
<div className="tiny" style={{ marginTop: 2 }}>tutor.puchstein.dev/s/K7QJMX2P</div>
|
||
</div>
|
||
<StatusPill status="open" />
|
||
<button className="btn ghost sm"><Icon.copy /> Kopieren</button>
|
||
<button className="btn"><Icon.lock /> Sperren</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ borderTop: "1px solid var(--rule)" }} />
|
||
|
||
{/* Body grid */}
|
||
<div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 380px", gap: 24, minHeight: 0 }}>
|
||
{/* Seat map column */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12, minHeight: 0 }}>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||
<div>
|
||
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>Sitzplan</div>
|
||
<UnderlineStroke width={70} />
|
||
</div>
|
||
<div style={{ display: "flex", gap: 14, alignItems: "center" }} className="small">
|
||
<LegendDot color="var(--ink)" label="anwesend" />
|
||
<LegendDot color="#f7f1e3" outline="var(--ink-4)" label="frei" />
|
||
<LegendDot color="var(--highlight-soft)" label="ausgewählt" />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", flex: 1 }}>
|
||
<SeatMap
|
||
assignments={SEAT_ASSIGN}
|
||
selectedStudent={selected}
|
||
hoveredSeat={hovered}
|
||
setHoveredSeat={setHovered}
|
||
onSeatClick={(seat) => {
|
||
const sid = SEAT_ASSIGN[seat.id];
|
||
if (sid) setSelected(sid);
|
||
}}
|
||
variant="tutor"
|
||
scale={0.85}
|
||
/>
|
||
</div>
|
||
|
||
{/* Tally */}
|
||
<div style={{ display: "flex", gap: 24, alignItems: "center", paddingTop: 4 }}>
|
||
<Tally label="Anwesend" value={present.length} total={STUDENTS.length} accent="var(--green)" />
|
||
<Tally label="Fehlt" value={absent.length} total={STUDENTS.length} accent="var(--accent)" />
|
||
<Tally label="Bonus heute" value={`+${present.length * 3}`} suffix="Punkte" />
|
||
<div style={{ flex: 1 }} />
|
||
<button className="btn ghost sm"><Icon.plus /> Manuell eintragen</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notes panel column */}
|
||
<div className="card" style={{ display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden" }}>
|
||
{/* Roster */}
|
||
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--rule)" }}>
|
||
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>Studierende</div>
|
||
<div className="tiny" style={{ marginTop: 2 }}>{present.length} anwesend · {absent.length} fehlen</div>
|
||
</div>
|
||
|
||
<div className="scroll" style={{ overflowY: "auto", maxHeight: 220, padding: "6px 0" }}>
|
||
{present.map((s) => {
|
||
const isSel = s.id === selected;
|
||
const hasNote = notes[s.id];
|
||
return (
|
||
<button key={s.id}
|
||
onClick={() => setSelected(s.id)}
|
||
className="row-hover"
|
||
style={{
|
||
width: "100%", textAlign: "left", border: "none", background: isSel ? "rgba(31,27,22,0.06)" : "transparent",
|
||
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
|
||
borderLeft: isSel ? "3px solid var(--ink)" : "3px solid transparent",
|
||
}}>
|
||
<span style={{
|
||
width: 22, height: 22, borderRadius: "50%",
|
||
background: "var(--ink)", color: "var(--paper)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
|
||
}}>{s.initials}</span>
|
||
<span style={{ flex: 1, fontSize: 13 }}>{s.name}</span>
|
||
{hasNote && <span title="hat Notiz" style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />}
|
||
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>{CHECKED_IN_AT[s.id]}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{absent.map((s) => (
|
||
<button key={s.id}
|
||
onClick={() => setSelected(s.id)}
|
||
className="row-hover"
|
||
style={{
|
||
width: "100%", textAlign: "left", border: "none", background: "transparent",
|
||
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
|
||
opacity: 0.55,
|
||
}}>
|
||
<span style={{
|
||
width: 22, height: 22, borderRadius: "50%",
|
||
background: "transparent", color: "var(--ink-3)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
|
||
border: "1px dashed var(--ink-4)",
|
||
}}>{s.initials}</span>
|
||
<span style={{ flex: 1, fontSize: 13, textDecoration: "line-through", textDecorationColor: "var(--ink-4)" }}>{s.name}</span>
|
||
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>—</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Note editor */}
|
||
<div style={{ borderTop: "1px solid var(--rule)", padding: "14px 16px", flex: 1, display: "flex", flexDirection: "column", background: "#fbf7ee" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||
<span style={{
|
||
width: 32, height: 32, borderRadius: "50%",
|
||
background: "var(--ink)", color: "var(--paper)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontFamily: "var(--sans)", fontSize: 11, fontWeight: 600,
|
||
}}>{sel?.initials}</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>{sel?.name}</div>
|
||
<div className="tiny">Sitzplatz {seatOf(selected) || "—"} · seit {CHECKED_IN_AT[selected] || "—"}</div>
|
||
</div>
|
||
<span className="stamp">Präsent</span>
|
||
</div>
|
||
|
||
<div className="eyebrow" style={{ marginTop: 6, marginBottom: 6 }}>Notiz · Woche 04</div>
|
||
<textarea
|
||
value={notes[selected] || ""}
|
||
onChange={(e) => setNotes({ ...notes, [selected]: e.target.value })}
|
||
placeholder="Beobachtungen für diese Woche…"
|
||
className="ruled"
|
||
style={{
|
||
flex: 1, minHeight: 110, resize: "none",
|
||
fontFamily: "var(--serif)", fontSize: 15, lineHeight: "28px",
|
||
padding: "0 0 4px 0",
|
||
background: "transparent", border: "none", outline: "none",
|
||
color: "var(--ink)",
|
||
}}
|
||
/>
|
||
|
||
{/* Quick tags */}
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
|
||
{["aktiv beteiligt", "stille:r Kämpfer:in", "verstanden ✓", "nochmal aufgreifen", "Rückfrage offen", "elegante Lösung"].map((tag) => (
|
||
<button key={tag} className="pill closed" style={{ borderColor: "var(--rule)", cursor: "pointer", fontFamily: "var(--sans)", textTransform: "none", fontSize: 11, letterSpacing: 0 }}>
|
||
+ {tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="tiny" style={{ marginTop: 10, display: "flex", justifyContent: "space-between" }}>
|
||
<span>Auto-gespeichert · 14:23</span>
|
||
<a href="#" style={{ color: "var(--ink-3)" }}>Notizen vergangener Wochen ↗</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegendDot({ color, outline, label }) {
|
||
return (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||
<span style={{
|
||
width: 12, height: 12, borderRadius: "50%",
|
||
background: color, border: outline ? `1px solid ${outline}` : "none",
|
||
}} />
|
||
<span>{label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Tally({ label, value, total, suffix, accent }) {
|
||
return (
|
||
<div>
|
||
<div className="eyebrow" style={{ marginBottom: 2 }}>{label}</div>
|
||
<div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
|
||
<span className="serif" style={{ fontSize: 28, fontWeight: 500, color: accent || "var(--ink)" }}>{value}</span>
|
||
{total != null && <span className="small">/ {total}</span>}
|
||
{suffix && <span className="small" style={{ marginLeft: 2 }}>{suffix}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, { SeatMap, TutorLiveView, LegendDot, Tally });
|