Add role-based trip views and keep expandos open after adding items
This commit is contained in:
185
main.go
185
main.go
@@ -19,7 +19,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
texttemplate "text/template"
|
texttemplate "text/template"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
"google.golang.org/api/idtoken"
|
"google.golang.org/api/idtoken"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ func main() {
|
|||||||
http.HandleFunc("DELETE /api/trips/{tripID}/admins/{adminID}", handleRemoveTripAdmin(db))
|
http.HandleFunc("DELETE /api/trips/{tripID}/admins/{adminID}", handleRemoveTripAdmin(db))
|
||||||
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}/me", handleTripMe(db))
|
||||||
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("PATCH /api/trips/{tripID}", handleUpdateTrip(db))
|
||||||
http.HandleFunc("GET /api/trips/{tripID}/students", handleListStudents(db))
|
http.HandleFunc("GET /api/trips/{tripID}/students", handleListStudents(db))
|
||||||
@@ -223,6 +224,57 @@ func requireTripAdmin(db *sql.DB, w http.ResponseWriter, r *http.Request) (strin
|
|||||||
return email, tripID, true
|
return email, tripID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tripRole(db *sql.DB, email string, tripID int64) (string, []int64) {
|
||||||
|
if isAdmin(email) || isTripAdmin(db, email, tripID) {
|
||||||
|
return "admin", nil
|
||||||
|
}
|
||||||
|
var studentIDs []int64
|
||||||
|
rows, _ := db.Query("SELECT id FROM students WHERE trip_id = $1 AND email = $2", tripID, email)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
rows.Scan(&id)
|
||||||
|
studentIDs = append(studentIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(studentIDs) > 0 {
|
||||||
|
return "student", studentIDs
|
||||||
|
}
|
||||||
|
rows2, _ := db.Query("SELECT s.id FROM parents p JOIN students s ON s.id = p.student_id WHERE s.trip_id = $1 AND p.email = $2", tripID, email)
|
||||||
|
if rows2 != nil {
|
||||||
|
defer rows2.Close()
|
||||||
|
for rows2.Next() {
|
||||||
|
var id int64
|
||||||
|
rows2.Scan(&id)
|
||||||
|
studentIDs = append(studentIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(studentIDs) > 0 {
|
||||||
|
return "parent", studentIDs
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireTripMember(db *sql.DB, w http.ResponseWriter, r *http.Request) (string, int64, string, []int64, bool) {
|
||||||
|
email, ok := authorize(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return "", 0, "", nil, false
|
||||||
|
}
|
||||||
|
tripID, err := strconv.ParseInt(r.PathValue("tripID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid trip ID", http.StatusBadRequest)
|
||||||
|
return "", 0, "", nil, false
|
||||||
|
}
|
||||||
|
role, studentIDs := tripRole(db, email, tripID)
|
||||||
|
if role == "" {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return "", 0, "", nil, false
|
||||||
|
}
|
||||||
|
return email, tripID, role, studentIDs, true
|
||||||
|
}
|
||||||
|
|
||||||
func handleAdminCheck(w http.ResponseWriter, r *http.Request) {
|
func handleAdminCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
email, ok := authorize(r)
|
email, ok := authorize(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -382,9 +434,34 @@ func handleRemoveTripAdmin(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleTripMe(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, tripID, role, studentIDs, ok := requireTripMember(db, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type studentInfo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
var students []studentInfo
|
||||||
|
for _, sid := range studentIDs {
|
||||||
|
var name string
|
||||||
|
if err := db.QueryRow("SELECT name FROM students WHERE id = $1 AND trip_id = $2", sid, tripID).Scan(&name); err == nil {
|
||||||
|
students = append(students, studentInfo{ID: sid, Name: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if students == nil {
|
||||||
|
students = []studentInfo{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"role": role, "students": students})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetTrip(db *sql.DB) http.HandlerFunc {
|
func handleGetTrip(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, tripID, ok := requireTripAdmin(db, w, r)
|
_, tripID, _, _, ok := requireTripMember(db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -402,10 +479,39 @@ func handleGetTrip(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleListStudents(db *sql.DB) http.HandlerFunc {
|
func handleListStudents(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, tripID, ok := requireTripAdmin(db, w, r)
|
_, tripID, role, _, ok := requireTripMember(db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if role != "admin" {
|
||||||
|
rows, err := db.Query("SELECT id, name FROM students WHERE trip_id = $1 ORDER BY name", tripID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type studentBasic struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
var students []studentBasic
|
||||||
|
for rows.Next() {
|
||||||
|
var s studentBasic
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
students = append(students, s)
|
||||||
|
}
|
||||||
|
if students == nil {
|
||||||
|
students = []studentBasic{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(students)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT s.id, s.name, s.email, COALESCE(
|
SELECT s.id, s.name, s.email, COALESCE(
|
||||||
json_agg(json_build_object('id', p.id, 'email', p.email)) FILTER (WHERE p.id IS NOT NULL),
|
json_agg(json_build_object('id', p.id, 'email', p.email)) FILTER (WHERE p.id IS NOT NULL),
|
||||||
@@ -612,17 +718,39 @@ func handleUpdateTrip(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleListConstraints(db *sql.DB) http.HandlerFunc {
|
func handleListConstraints(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, tripID, ok := requireTripAdmin(db, w, r)
|
_, tripID, role, myStudentIDs, ok := requireTripMember(db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := db.Query(`
|
var query string
|
||||||
SELECT rc.id, rc.student_a_id, sa.name, rc.student_b_id, sb.name, rc.kind::text, rc.level::text
|
var args []any
|
||||||
FROM roommate_constraints rc
|
switch role {
|
||||||
JOIN students sa ON sa.id = rc.student_a_id
|
case "admin":
|
||||||
JOIN students sb ON sb.id = rc.student_b_id
|
query = `SELECT rc.id, rc.student_a_id, sa.name, rc.student_b_id, sb.name, rc.kind::text, rc.level::text
|
||||||
WHERE sa.trip_id = $1
|
FROM roommate_constraints rc
|
||||||
ORDER BY rc.id`, tripID)
|
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`
|
||||||
|
args = []any{tripID}
|
||||||
|
case "student":
|
||||||
|
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 AND rc.level = 'student' AND rc.student_a_id = ANY($2)
|
||||||
|
ORDER BY rc.id`
|
||||||
|
args = []any{tripID, pq.Array(myStudentIDs)}
|
||||||
|
case "parent":
|
||||||
|
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 AND rc.level = 'parent' AND rc.student_a_id = ANY($2)
|
||||||
|
ORDER BY rc.id`
|
||||||
|
args = []any{tripID, pq.Array(myStudentIDs)}
|
||||||
|
}
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -658,7 +786,7 @@ func handleListConstraints(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleCreateConstraint(db *sql.DB) http.HandlerFunc {
|
func handleCreateConstraint(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, tripID, ok := requireTripAdmin(db, w, r)
|
_, tripID, role, myStudentIDs, ok := requireTripMember(db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -692,6 +820,23 @@ func handleCreateConstraint(db *sql.DB) http.HandlerFunc {
|
|||||||
http.Error(w, "invalid level", http.StatusBadRequest)
|
http.Error(w, "invalid level", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if role != "admin" {
|
||||||
|
if body.Level != role {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owns := false
|
||||||
|
for _, sid := range myStudentIDs {
|
||||||
|
if sid == body.StudentAID {
|
||||||
|
owns = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !owns {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var id int64
|
var id int64
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
INSERT INTO roommate_constraints (student_a_id, student_b_id, kind, level)
|
INSERT INTO roommate_constraints (student_a_id, student_b_id, kind, level)
|
||||||
@@ -712,7 +857,7 @@ func handleCreateConstraint(db *sql.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleDeleteConstraint(db *sql.DB) http.HandlerFunc {
|
func handleDeleteConstraint(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, tripID, ok := requireTripAdmin(db, w, r)
|
_, tripID, role, myStudentIDs, ok := requireTripMember(db, w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -721,8 +866,18 @@ func handleDeleteConstraint(db *sql.DB) http.HandlerFunc {
|
|||||||
http.Error(w, "invalid constraint ID", http.StatusBadRequest)
|
http.Error(w, "invalid constraint ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := db.Exec(`DELETE FROM roommate_constraints WHERE id = $1
|
var query string
|
||||||
AND student_a_id IN (SELECT id FROM students WHERE trip_id = $2)`, constraintID, tripID)
|
var args []any
|
||||||
|
if role == "admin" {
|
||||||
|
query = `DELETE FROM roommate_constraints WHERE id = $1
|
||||||
|
AND student_a_id IN (SELECT id FROM students WHERE trip_id = $2)`
|
||||||
|
args = []any{constraintID, tripID}
|
||||||
|
} else {
|
||||||
|
query = `DELETE FROM roommate_constraints WHERE id = $1
|
||||||
|
AND student_a_id = ANY($2) AND level = $3::constraint_level`
|
||||||
|
args = []any{constraintID, pq.Array(myStudentIDs), role}
|
||||||
|
}
|
||||||
|
result, err := db.Exec(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ async function loadTrips() {
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const trip of trips) {
|
for (const trip of trips) {
|
||||||
const card = document.createElement('wa-card');
|
const card = document.createElement('wa-card');
|
||||||
|
card.dataset.tripId = trip.id;
|
||||||
|
|
||||||
const nameRow = document.createElement('div');
|
const nameRow = document.createElement('div');
|
||||||
nameRow.style.display = 'flex';
|
nameRow.style.display = 'flex';
|
||||||
@@ -77,7 +78,14 @@ async function loadTrips() {
|
|||||||
const email = input.value.trim();
|
const email = input.value.trim();
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
await api('POST', '/api/trips/' + trip.id + '/admins', { email });
|
await api('POST', '/api/trips/' + trip.id + '/admins', { email });
|
||||||
loadTrips();
|
await loadTrips();
|
||||||
|
const card = container.querySelector('[data-trip-id="' + trip.id + '"]');
|
||||||
|
if (card) {
|
||||||
|
const det = card.querySelector('wa-details');
|
||||||
|
if (det) det.open = true;
|
||||||
|
const inp = card.querySelector('wa-input');
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
addBtn.addEventListener('click', doAdd);
|
addBtn.addEventListener('click', doAdd);
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); });
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); });
|
||||||
|
|||||||
@@ -73,6 +73,9 @@
|
|||||||
.room-label { font-weight: bold; font-size: 0.8rem; margin-bottom: 0.2rem; }
|
.room-label { font-weight: bold; font-size: 0.8rem; margin-bottom: 0.2rem; }
|
||||||
.solver-score { font-size: 0.8rem; margin-top: 0.3rem; color: var(--wa-color-neutral-500); }
|
.solver-score { font-size: 0.8rem; margin-top: 0.3rem; color: var(--wa-color-neutral-500); }
|
||||||
.divider { border: none; border-top: 1px solid #909090; margin: 0.75rem 0; }
|
.divider { border: none; border-top: 1px solid #909090; margin: 0.75rem 0; }
|
||||||
|
.pref-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
|
||||||
|
.pref-row .pref-name { flex: 1; font-size: 0.85rem; }
|
||||||
|
.pref-row wa-select { width: 14rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -85,28 +88,33 @@
|
|||||||
<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">
|
<div id="admin-view" style="display: none;">
|
||||||
<label>Students per room: <input id="room-size" type="number" min="1"></label>
|
<div id="trip-settings">
|
||||||
<label>Prefer Not cost: <input id="pn-multiple" type="number" min="1"></label>
|
<label>Students per room: <input id="room-size" type="number" min="1"></label>
|
||||||
<label>No Prefer cost: <input id="np-cost" type="number" min="0"></label>
|
<label>Prefer Not cost: <input id="pn-multiple" type="number" min="1"></label>
|
||||||
</div>
|
<label>No Prefer cost: <input id="np-cost" type="number" min="0"></label>
|
||||||
<hr class="divider">
|
|
||||||
<div id="conflicts"></div>
|
|
||||||
<div id="mismatches"></div>
|
|
||||||
<div id="hard-conflicts"></div>
|
|
||||||
<div id="solver">
|
|
||||||
<wa-button id="solve-btn" size="small">Solve Rooms</wa-button>
|
|
||||||
<div id="solver-results"></div>
|
|
||||||
</div>
|
|
||||||
<hr class="divider">
|
|
||||||
<div id="students"></div>
|
|
||||||
<wa-details summary="Add Student">
|
|
||||||
<div class="add-form">
|
|
||||||
<wa-input id="new-student-name" placeholder="Name" size="small"></wa-input>
|
|
||||||
<wa-input id="new-student-email" placeholder="Email" size="small"></wa-input>
|
|
||||||
<wa-button size="small" id="add-student-btn">Add Student</wa-button>
|
|
||||||
</div>
|
</div>
|
||||||
</wa-details>
|
<hr class="divider">
|
||||||
|
<div id="conflicts"></div>
|
||||||
|
<div id="mismatches"></div>
|
||||||
|
<div id="hard-conflicts"></div>
|
||||||
|
<div id="solver">
|
||||||
|
<wa-button id="solve-btn" size="small">Solve Rooms</wa-button>
|
||||||
|
<div id="solver-results"></div>
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
<div id="students"></div>
|
||||||
|
<wa-details summary="Add Student">
|
||||||
|
<div class="add-form">
|
||||||
|
<wa-input id="new-student-name" placeholder="Name" size="small"></wa-input>
|
||||||
|
<wa-input id="new-student-email" placeholder="Email" size="small"></wa-input>
|
||||||
|
<wa-button size="small" id="add-student-btn">Add Student</wa-button>
|
||||||
|
</div>
|
||||||
|
</wa-details>
|
||||||
|
</div>
|
||||||
|
<div id="member-view" style="display: none;">
|
||||||
|
<div id="member-students"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/trip.js"></script>
|
<script type="module" src="/trip.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
112
static/trip.js
112
static/trip.js
@@ -5,9 +5,12 @@ const tripID = location.pathname.split('/').pop();
|
|||||||
|
|
||||||
const profile = await init();
|
const profile = await init();
|
||||||
|
|
||||||
let trip;
|
let trip, me;
|
||||||
try {
|
try {
|
||||||
trip = await api('GET', '/api/trips/' + tripID);
|
[trip, me] = await Promise.all([
|
||||||
|
api('GET', '/api/trips/' + tripID),
|
||||||
|
api('GET', '/api/trips/' + tripID + '/me')
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.style.opacity = 1;
|
document.body.style.opacity = 1;
|
||||||
document.body.textContent = 'Access denied.';
|
document.body.textContent = 'Access denied.';
|
||||||
@@ -15,11 +18,21 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('trip-name').textContent = trip.name;
|
document.getElementById('trip-name').textContent = trip.name;
|
||||||
|
document.getElementById('main').style.display = 'block';
|
||||||
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||||
|
|
||||||
|
if (me.role !== 'admin') {
|
||||||
|
document.getElementById('member-view').style.display = 'block';
|
||||||
|
await renderMemberView(me);
|
||||||
|
await customElements.whenDefined('wa-button');
|
||||||
|
document.body.style.opacity = 1;
|
||||||
|
} else {
|
||||||
|
await (async () => {
|
||||||
|
|
||||||
|
document.getElementById('admin-view').style.display = 'block';
|
||||||
document.getElementById('room-size').value = trip.room_size;
|
document.getElementById('room-size').value = trip.room_size;
|
||||||
document.getElementById('pn-multiple').value = trip.prefer_not_multiple;
|
document.getElementById('pn-multiple').value = trip.prefer_not_multiple;
|
||||||
document.getElementById('np-cost').value = trip.no_prefer_cost;
|
document.getElementById('np-cost').value = trip.no_prefer_cost;
|
||||||
document.getElementById('main').style.display = 'block';
|
|
||||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
|
||||||
document.getElementById('room-size').addEventListener('change', async () => {
|
document.getElementById('room-size').addEventListener('change', async () => {
|
||||||
const size = parseInt(document.getElementById('room-size').value);
|
const size = parseInt(document.getElementById('room-size').value);
|
||||||
if (size >= 1) await api('PATCH', '/api/trips/' + tripID, { room_size: size });
|
if (size >= 1) await api('PATCH', '/api/trips/' + tripID, { room_size: size });
|
||||||
@@ -334,10 +347,17 @@ async function loadStudents() {
|
|||||||
addBtn.className = 'input-action';
|
addBtn.className = 'input-action';
|
||||||
addBtn.textContent = '+';
|
addBtn.textContent = '+';
|
||||||
const doAdd = async () => {
|
const doAdd = async () => {
|
||||||
const email = input.value.trim();
|
const email = (input.value || '').trim();
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
await api('POST', '/api/trips/' + tripID + '/students/' + student.id + '/parents', { email });
|
await api('POST', '/api/trips/' + tripID + '/students/' + student.id + '/parents', { email });
|
||||||
loadStudents();
|
await loadStudents();
|
||||||
|
const reCard = container.querySelector('[data-student-id="' + student.id + '"]');
|
||||||
|
if (reCard) {
|
||||||
|
const det = reCard.querySelector('wa-details');
|
||||||
|
if (det) det.open = true;
|
||||||
|
const inp = reCard.querySelector('wa-input');
|
||||||
|
if (inp) inp.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
addBtn.addEventListener('click', doAdd);
|
addBtn.addEventListener('click', doAdd);
|
||||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); });
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAdd(); });
|
||||||
@@ -605,3 +625,83 @@ if (DOMAIN) {
|
|||||||
if (parts.length >= 2) emailInput.value = parts.join('.') + '@' + DOMAIN;
|
if (parts.length >= 2) emailInput.value = parts.join('.') + '@' + DOMAIN;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMemberView(me) {
|
||||||
|
const [students, constraints] = await Promise.all([
|
||||||
|
api('GET', '/api/trips/' + tripID + '/students'),
|
||||||
|
api('GET', '/api/trips/' + tripID + '/constraints')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const myStudentIDs = new Set(me.students.map(s => s.id));
|
||||||
|
const container = document.getElementById('member-students');
|
||||||
|
|
||||||
|
const kindLabels = me.role === 'student'
|
||||||
|
? { '': 'OK to room with', prefer: 'Would like to room with', prefer_not: 'Would prefer not to room with' }
|
||||||
|
: { '': 'OK to room with', must_not: 'Not OK to room with' };
|
||||||
|
|
||||||
|
const kindOptions = me.role === 'student'
|
||||||
|
? ['', 'prefer', 'prefer_not']
|
||||||
|
: ['', 'must_not'];
|
||||||
|
|
||||||
|
for (const myStudent of me.students) {
|
||||||
|
const card = document.createElement('wa-card');
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'student-name';
|
||||||
|
label.textContent = myStudent.name;
|
||||||
|
card.appendChild(label);
|
||||||
|
|
||||||
|
const myConstraints = {};
|
||||||
|
for (const c of constraints) {
|
||||||
|
if (c.student_a_id === myStudent.id) {
|
||||||
|
myConstraints[c.student_b_id] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const other of students) {
|
||||||
|
if (myStudentIDs.has(other.id)) continue;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pref-row';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'pref-name';
|
||||||
|
name.textContent = other.name;
|
||||||
|
row.appendChild(name);
|
||||||
|
|
||||||
|
const select = document.createElement('wa-select');
|
||||||
|
select.size = 'small';
|
||||||
|
for (const kind of kindOptions) {
|
||||||
|
const opt = document.createElement('wa-option');
|
||||||
|
opt.value = kind;
|
||||||
|
opt.textContent = kindLabels[kind];
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
const existing = myConstraints[other.id];
|
||||||
|
const initVal = existing ? existing.kind : '';
|
||||||
|
select.updateComplete.then(() => { select.value = initVal; });
|
||||||
|
|
||||||
|
select.addEventListener('change', async (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
const c = myConstraints[other.id];
|
||||||
|
if (c) {
|
||||||
|
await api('DELETE', '/api/trips/' + tripID + '/constraints/' + c.id);
|
||||||
|
delete myConstraints[other.id];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await api('POST', '/api/trips/' + tripID + '/constraints', {
|
||||||
|
student_a_id: myStudent.id,
|
||||||
|
student_b_id: other.id,
|
||||||
|
kind: val,
|
||||||
|
level: me.role
|
||||||
|
});
|
||||||
|
myConstraints[other.id] = { id: result.id, kind: val, student_a_id: myStudent.id, student_b_id: other.id };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.appendChild(select);
|
||||||
|
card.appendChild(row);
|
||||||
|
}
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user