Add room size setting, constraint management with grouped color-coded tags
This commit is contained in:
157
main.go
157
main.go
@@ -68,11 +68,15 @@ func main() {
|
|||||||
http.HandleFunc("GET /trip/{tripID}", serveHTML("trip.html"))
|
http.HandleFunc("GET /trip/{tripID}", serveHTML("trip.html"))
|
||||||
http.HandleFunc("GET /trip.js", serveJS("trip.js"))
|
http.HandleFunc("GET /trip.js", serveJS("trip.js"))
|
||||||
http.HandleFunc("GET /api/trips/{tripID}", handleGetTrip(db))
|
http.HandleFunc("GET /api/trips/{tripID}", handleGetTrip(db))
|
||||||
|
http.HandleFunc("PATCH /api/trips/{tripID}", handleUpdateTrip(db))
|
||||||
http.HandleFunc("GET /api/trips/{tripID}/students", handleListStudents(db))
|
http.HandleFunc("GET /api/trips/{tripID}/students", handleListStudents(db))
|
||||||
http.HandleFunc("POST /api/trips/{tripID}/students", handleCreateStudent(db))
|
http.HandleFunc("POST /api/trips/{tripID}/students", handleCreateStudent(db))
|
||||||
http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}", handleDeleteStudent(db))
|
http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}", handleDeleteStudent(db))
|
||||||
http.HandleFunc("POST /api/trips/{tripID}/students/{studentID}/parents", handleAddParent(db))
|
http.HandleFunc("POST /api/trips/{tripID}/students/{studentID}/parents", handleAddParent(db))
|
||||||
http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}/parents/{parentID}", handleRemoveParent(db))
|
http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}/parents/{parentID}", handleRemoveParent(db))
|
||||||
|
http.HandleFunc("GET /api/trips/{tripID}/constraints", handleListConstraints(db))
|
||||||
|
http.HandleFunc("POST /api/trips/{tripID}/constraints", handleCreateConstraint(db))
|
||||||
|
http.HandleFunc("DELETE /api/trips/{tripID}/constraints/{constraintID}", handleDeleteConstraint(db))
|
||||||
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
http.Error(w, "db unhealthy", http.StatusServiceUnavailable)
|
http.Error(w, "db unhealthy", http.StatusServiceUnavailable)
|
||||||
@@ -232,13 +236,13 @@ func handleListTrips(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT t.id, t.name, COALESCE(
|
SELECT t.id, t.name, t.room_size, COALESCE(
|
||||||
json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL),
|
json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL),
|
||||||
'[]'
|
'[]'
|
||||||
)
|
)
|
||||||
FROM trips t
|
FROM trips t
|
||||||
LEFT JOIN trip_admins ta ON ta.trip_id = t.id
|
LEFT JOIN trip_admins ta ON ta.trip_id = t.id
|
||||||
GROUP BY t.id, t.name
|
GROUP BY t.id, t.name, t.room_size
|
||||||
ORDER BY t.id`)
|
ORDER BY t.id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -251,16 +255,17 @@ func handleListTrips(db *sql.DB) http.HandlerFunc {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
type trip struct {
|
type trip struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Admins []tripAdmin `json:"admins"`
|
RoomSize int `json:"room_size"`
|
||||||
|
Admins []tripAdmin `json:"admins"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var trips []trip
|
var trips []trip
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t trip
|
var t trip
|
||||||
var adminsJSON string
|
var adminsJSON string
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &adminsJSON); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &t.RoomSize, &adminsJSON); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -379,13 +384,14 @@ func handleGetTrip(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var name string
|
var name string
|
||||||
err := db.QueryRow("SELECT name FROM trips WHERE id = $1", tripID).Scan(&name)
|
var roomSize int
|
||||||
|
err := db.QueryRow("SELECT name, room_size FROM trips WHERE id = $1", tripID).Scan(&name, &roomSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "trip not found", http.StatusNotFound)
|
http.Error(w, "trip not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name})
|
json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name, "room_size": roomSize})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,3 +549,138 @@ func handleRemoveParent(db *sql.DB) http.HandlerFunc {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleUpdateTrip(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, tripID, ok := requireTripAdmin(db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
RoomSize int `json:"room_size"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RoomSize < 1 {
|
||||||
|
http.Error(w, "room_size must be at least 1", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := db.Exec("UPDATE trips SET room_size = $1 WHERE id = $2", body.RoomSize, tripID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListConstraints(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, tripID, ok := requireTripAdmin(db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT rc.id, rc.student_a_id, sa.name, rc.student_b_id, sb.name, rc.kind::text, rc.level::text
|
||||||
|
FROM roommate_constraints rc
|
||||||
|
JOIN students sa ON sa.id = rc.student_a_id
|
||||||
|
JOIN students sb ON sb.id = rc.student_b_id
|
||||||
|
WHERE sa.trip_id = $1
|
||||||
|
ORDER BY rc.id`, tripID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type constraint struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
StudentAID int64 `json:"student_a_id"`
|
||||||
|
StudentAName string `json:"student_a_name"`
|
||||||
|
StudentBID int64 `json:"student_b_id"`
|
||||||
|
StudentBName string `json:"student_b_name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var constraints []constraint
|
||||||
|
for rows.Next() {
|
||||||
|
var c constraint
|
||||||
|
if err := rows.Scan(&c.ID, &c.StudentAID, &c.StudentAName, &c.StudentBID, &c.StudentBName, &c.Kind, &c.Level); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
constraints = append(constraints, c)
|
||||||
|
}
|
||||||
|
if constraints == nil {
|
||||||
|
constraints = []constraint{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(constraints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateConstraint(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, tripID, ok := requireTripAdmin(db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
StudentAID int64 `json:"student_a_id"`
|
||||||
|
StudentBID int64 `json:"student_b_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.StudentAID == body.StudentBID {
|
||||||
|
http.Error(w, "students must be different", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, b := body.StudentAID, body.StudentBID
|
||||||
|
if a > b {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
err := db.QueryRow(`
|
||||||
|
INSERT INTO roommate_constraints (student_a_id, student_b_id, kind, level)
|
||||||
|
SELECT $1, $2, $3::constraint_kind, $4::constraint_level
|
||||||
|
FROM students sa
|
||||||
|
JOIN students sb ON sb.id = $2 AND sb.trip_id = $5
|
||||||
|
WHERE sa.id = $1 AND sa.trip_id = $5
|
||||||
|
ON CONFLICT (student_a_id, student_b_id, level) DO UPDATE SET kind = EXCLUDED.kind
|
||||||
|
RETURNING id`, a, b, body.Kind, body.Level, tripID).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteConstraint(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, tripID, ok := requireTripAdmin(db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
constraintID, err := strconv.ParseInt(r.PathValue("constraintID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid constraint ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := db.Exec(`DELETE FROM roommate_constraints WHERE id = $1
|
||||||
|
AND student_a_id IN (SELECT id FROM students WHERE trip_id = $2)`, constraintID, tripID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := result.RowsAffected(); n == 0 {
|
||||||
|
http.Error(w, "constraint not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
DROP TABLE IF EXISTS roommate_constraints;
|
||||||
|
DROP TYPE IF EXISTS constraint_kind;
|
||||||
|
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE constraint_kind AS ENUM ('happy', 'ok', 'never');
|
CREATE TYPE constraint_kind AS ENUM ('must', 'prefer', 'prefer_not', 'must_not');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
@@ -13,6 +16,8 @@ CREATE TABLE IF NOT EXISTS trips (
|
|||||||
name TEXT NOT NULL
|
name TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE trips ADD COLUMN IF NOT EXISTS room_size INTEGER NOT NULL DEFAULT 2;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trip_admins (
|
CREATE TABLE IF NOT EXISTS trip_admins (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
@@ -41,5 +46,6 @@ CREATE TABLE IF NOT EXISTS roommate_constraints (
|
|||||||
student_b_id BIGINT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
student_b_id BIGINT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
kind constraint_kind NOT NULL,
|
kind constraint_kind NOT NULL,
|
||||||
level constraint_level NOT NULL,
|
level constraint_level NOT NULL,
|
||||||
|
CHECK(student_a_id < student_b_id),
|
||||||
UNIQUE(student_a_id, student_b_id, level)
|
UNIQUE(student_a_id, student_b_id, level)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function api(method, path, body) {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await res.text());
|
throw new Error(await res.text());
|
||||||
}
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
}
|
}
|
||||||
.input-action:hover { opacity: 1; }
|
.input-action:hover { opacity: 1; }
|
||||||
|
#trip-settings { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||||
|
#room-size { width: 3.5rem; font-size: 0.85rem; padding: 0.2rem; border: 1px solid var(--wa-color-neutral-300, #ccc); border-radius: 0.25rem; }
|
||||||
|
.constraint-group { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; margin-bottom: 0.2rem; }
|
||||||
|
.constraint-level { font-size: 0.7rem; font-weight: bold; opacity: 0.6; }
|
||||||
|
.constraint-add { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.3rem; }
|
||||||
|
.constraint-add select { font-size: 0.75rem; padding: 0.15rem; border: 1px solid var(--wa-color-neutral-300, #ccc); border-radius: 0.25rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -66,6 +72,10 @@
|
|||||||
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="trip-name"></h2>
|
<h2 id="trip-name"></h2>
|
||||||
|
<div id="trip-settings">
|
||||||
|
<label for="room-size">Students per room:</label>
|
||||||
|
<input id="room-size" type="number" min="1">
|
||||||
|
</div>
|
||||||
<div id="students"></div>
|
<div id="students"></div>
|
||||||
<wa-details summary="Add Student">
|
<wa-details summary="Add Student">
|
||||||
<div class="add-form">
|
<div class="add-form">
|
||||||
|
|||||||
111
static/trip.js
111
static/trip.js
@@ -14,15 +14,31 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('trip-name').textContent = trip.name;
|
document.getElementById('trip-name').textContent = trip.name;
|
||||||
|
document.getElementById('room-size').value = trip.room_size;
|
||||||
document.getElementById('main').style.display = 'block';
|
document.getElementById('main').style.display = 'block';
|
||||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||||
|
document.getElementById('room-size').addEventListener('change', async () => {
|
||||||
|
const size = parseInt(document.getElementById('room-size').value);
|
||||||
|
if (size >= 1) await api('PATCH', '/api/trips/' + tripID, { room_size: size });
|
||||||
|
});
|
||||||
|
|
||||||
async function loadStudents() {
|
async function loadStudents() {
|
||||||
const students = await api('GET', '/api/trips/' + tripID + '/students');
|
const [students, constraints] = await Promise.all([
|
||||||
|
api('GET', '/api/trips/' + tripID + '/students'),
|
||||||
|
api('GET', '/api/trips/' + tripID + '/constraints')
|
||||||
|
]);
|
||||||
const container = document.getElementById('students');
|
const container = document.getElementById('students');
|
||||||
|
const openStates = {};
|
||||||
|
for (const card of container.children) {
|
||||||
|
const sid = card.dataset.studentId;
|
||||||
|
if (!sid) continue;
|
||||||
|
openStates[sid] = {};
|
||||||
|
for (const det of card.querySelectorAll('wa-details')) openStates[sid][det.summary] = det.open;
|
||||||
|
}
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const student of students) {
|
for (const student of students) {
|
||||||
const card = document.createElement('wa-card');
|
const card = document.createElement('wa-card');
|
||||||
|
card.dataset.studentId = student.id;
|
||||||
|
|
||||||
const nameRow = document.createElement('div');
|
const nameRow = document.createElement('div');
|
||||||
nameRow.style.display = 'flex';
|
nameRow.style.display = 'flex';
|
||||||
@@ -84,6 +100,99 @@ async function loadStudents() {
|
|||||||
|
|
||||||
card.appendChild(details);
|
card.appendChild(details);
|
||||||
|
|
||||||
|
const cDetails = document.createElement('wa-details');
|
||||||
|
cDetails.summary = 'Constraints';
|
||||||
|
|
||||||
|
const kindVariant = { must: 'success', prefer: 'brand', prefer_not: 'warning', must_not: 'danger' };
|
||||||
|
const kindLabels = { must: 'Must', prefer: 'Prefer', prefer_not: 'Prefer Not', must_not: 'Must Not' };
|
||||||
|
const kindOrder = { must: 0, prefer: 1, prefer_not: 2, must_not: 3 };
|
||||||
|
const myConstraints = constraints.filter(c => c.student_a_id === student.id || c.student_b_id === student.id);
|
||||||
|
|
||||||
|
for (const level of ['admin', 'parent', 'student']) {
|
||||||
|
const lc = myConstraints.filter(c => c.level === level);
|
||||||
|
if (lc.length === 0) continue;
|
||||||
|
lc.sort((a, b) => {
|
||||||
|
const kd = kindOrder[a.kind] - kindOrder[b.kind];
|
||||||
|
if (kd !== 0) return kd;
|
||||||
|
const na = a.student_a_id === student.id ? a.student_b_name : a.student_a_name;
|
||||||
|
const nb = b.student_a_id === student.id ? b.student_b_name : b.student_a_name;
|
||||||
|
return na.localeCompare(nb);
|
||||||
|
});
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'constraint-group';
|
||||||
|
const levelLabel = document.createElement('span');
|
||||||
|
levelLabel.className = 'constraint-level';
|
||||||
|
levelLabel.textContent = level.charAt(0).toUpperCase() + level.slice(1);
|
||||||
|
group.appendChild(levelLabel);
|
||||||
|
for (const c of lc) {
|
||||||
|
const otherName = c.student_a_id === student.id ? c.student_b_name : c.student_a_name;
|
||||||
|
const tag = document.createElement('wa-tag');
|
||||||
|
tag.size = 'small';
|
||||||
|
tag.variant = kindVariant[c.kind];
|
||||||
|
tag.setAttribute('with-remove', '');
|
||||||
|
tag.textContent = kindLabels[c.kind] + ': ' + otherName;
|
||||||
|
tag.addEventListener('wa-remove', async () => {
|
||||||
|
await api('DELETE', '/api/trips/' + tripID + '/constraints/' + c.id);
|
||||||
|
loadStudents();
|
||||||
|
});
|
||||||
|
group.appendChild(tag);
|
||||||
|
}
|
||||||
|
cDetails.appendChild(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRow = document.createElement('div');
|
||||||
|
addRow.className = 'constraint-add';
|
||||||
|
const studentSelect = document.createElement('select');
|
||||||
|
const defaultOpt = document.createElement('option');
|
||||||
|
defaultOpt.value = '';
|
||||||
|
defaultOpt.textContent = 'Student\u2026';
|
||||||
|
studentSelect.appendChild(defaultOpt);
|
||||||
|
for (const other of students) {
|
||||||
|
if (other.id === student.id) continue;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = other.id;
|
||||||
|
opt.textContent = other.name;
|
||||||
|
studentSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const kindSelect = document.createElement('select');
|
||||||
|
for (const kind of ['must', 'prefer', 'prefer_not', 'must_not']) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = kind;
|
||||||
|
opt.textContent = kindLabels[kind];
|
||||||
|
kindSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const levelSelect = document.createElement('select');
|
||||||
|
for (const level of ['student', 'parent', 'admin']) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = level;
|
||||||
|
opt.textContent = level.charAt(0).toUpperCase() + level.slice(1);
|
||||||
|
levelSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const cAddBtn = document.createElement('button');
|
||||||
|
cAddBtn.className = 'input-action';
|
||||||
|
cAddBtn.textContent = '+';
|
||||||
|
cAddBtn.addEventListener('click', async () => {
|
||||||
|
const otherID = parseInt(studentSelect.value);
|
||||||
|
if (!otherID) return;
|
||||||
|
await api('POST', '/api/trips/' + tripID + '/constraints', {
|
||||||
|
student_a_id: student.id,
|
||||||
|
student_b_id: otherID,
|
||||||
|
kind: kindSelect.value,
|
||||||
|
level: levelSelect.value
|
||||||
|
});
|
||||||
|
loadStudents();
|
||||||
|
});
|
||||||
|
addRow.appendChild(studentSelect);
|
||||||
|
addRow.appendChild(kindSelect);
|
||||||
|
addRow.appendChild(levelSelect);
|
||||||
|
addRow.appendChild(cAddBtn);
|
||||||
|
cDetails.appendChild(addRow);
|
||||||
|
|
||||||
|
card.appendChild(cDetails);
|
||||||
|
|
||||||
|
const saved = openStates[student.id];
|
||||||
|
if (saved) for (const det of card.querySelectorAll('wa-details')) if (saved[det.summary]) det.open = true;
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user