diff --git a/internal/http/board.go b/internal/http/board.go index 50a4c57..7294102 100644 --- a/internal/http/board.go +++ b/internal/http/board.go @@ -19,7 +19,9 @@ import ( func (s *Server) routesBoard(mux *http.ServeMux) { dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) } - mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard)))) + // Developers and consultants can both read the board (consultants browse to + // open task detail; claim/start/etc. below stay developer-only). + mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard))) mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks)))) mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim)) mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim)) @@ -39,17 +41,10 @@ func (s *Server) routesBoard(mux *http.ServeMux) { // assigned to a customer manage its tasks, so a task published by any of // them belongs to the same board. func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) { - consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID) - if err != nil { - return nil, err - } + me := CurrentUser(r.Context()) seen := map[string]bool{} out := []domain.Customer{} - for _, cid := range consultants { - customers, err := s.store.ListCustomers(r.Context(), cid, false) - if err != nil { - return nil, err - } + add := func(customers []domain.Customer) { for _, c := range customers { if !seen[c.ID] { seen[c.ID] = true @@ -57,6 +52,28 @@ func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) { } } } + // Developers: every customer that has a consultant who pooled them. + if me.Roles.Developer { + consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID) + if err != nil { + return nil, err + } + for _, cid := range consultants { + customers, err := s.store.ListCustomers(r.Context(), cid, false) + if err != nil { + return nil, err + } + add(customers) + } + } + // Consultants: every customer they are assigned to. + if me.Roles.Consultant { + customers, err := s.store.ListCustomers(r.Context(), me.ID, false) + if err != nil { + return nil, err + } + add(customers) + } return out, nil } diff --git a/internal/http/web.go b/internal/http/web.go index aa4b0b7..783ccba 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -238,7 +238,10 @@ func (s *Server) routesWeb(mux *http.ServeMux) { } isDev := func(u *domain.User) bool { return u.Roles.Developer } isCons := func(u *domain.User) bool { return u.Roles.Consultant } - rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev) + isDevOrCons := func(u *domain.User) bool { return u.Roles.Developer || u.Roles.Consultant } + // Consultants can browse the board too (read-only: they open task detail to + // review comments/assignments); only developers see claim actions. + rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDevOrCons) rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev) rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons) rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons) diff --git a/web/static/css/app.css b/web/static/css/app.css index b6fccd2..4d93eea 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -766,6 +766,23 @@ input[type=range] { width: 100%; accent-color: var(--accent); } border-color: color-mix(in srgb, var(--accent) 70%, black); } +/* ---- @-mention autocomplete menu ---- */ +.mention-menu { + position: absolute; z-index: 80; + background: var(--surface); border: var(--hairline); + border-radius: var(--radius); + box-shadow: var(--ink-shadow); + max-height: 220px; overflow-y: auto; + padding: 4px; +} +.mention-row { + display: block; width: 100%; text-align: left; + font: inherit; font-size: 0.9rem; + background: none; border: none; color: var(--text); + padding: 7px 10px; cursor: pointer; border-radius: var(--radius); +} +.mention-row.active, .mention-row:hover { background: var(--surface2); } + /* ---- dialogs: paper slips ---- */ dialog { background: var(--surface); color: var(--text); diff --git a/web/static/js/atomization.js b/web/static/js/atomization.js index 9c5bc3b..dc3f4f6 100644 --- a/web/static/js/atomization.js +++ b/web/static/js/atomization.js @@ -82,7 +82,7 @@ function renderRoot(root) { ? 'orphaned' : ''; card.innerHTML = `
-

${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}

+

${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}

${esc(cust ? cust.name : '')} ${root.external ? 'ยท ' + esc(root.external.key) : ''}

${esc((root.description || '').slice(0, 240))}

@@ -135,7 +135,7 @@ function renderChildren(parent, kids, depth) {
${k.status === 'atomized' ? `` : ''} - ${esc(k.title)} ${ext} ${statusBadge(k)} + ${esc(k.title)} ${ext} ${statusBadge(k)} bounty ${k.bounty}
diff --git a/web/static/js/board.js b/web/static/js/board.js index 8fe8b6d..94e65de 100644 --- a/web/static/js/board.js +++ b/web/static/js/board.js @@ -4,6 +4,7 @@ import { subscribe, onPollFallback } from '/static/js/ws.js'; const grid = document.getElementById('board-grid'); const errorBox = document.getElementById('error'); let meId = ''; +let isDeveloper = false; let tasks = []; const customerNames = new Map(); @@ -69,9 +70,11 @@ function renderCard(t) { ${myClaim ? 'requested by you' : ''} ${others ? `${others} other request(s)` : ''} - ${myClaim - ? '' - : ''} + ${!isDeveloper + ? 'Open' + : myClaim + ? '' + : ''} `; const claimBtn = card.querySelector('[data-act=claim]'); if (claimBtn) claimBtn.addEventListener('click', () => openClaim(t)); @@ -121,6 +124,7 @@ async function restoreFilters() { try { const me = await api('GET', '/api/v1/auth/me'); meId = me.user.id; + isDeveloper = !!(me.user.roles && me.user.roles.developer); const saved = me.user.extra && me.user.extra.savedBoardFilters; if (saved) { const f = JSON.parse(saved); diff --git a/web/static/js/chat-widget.js b/web/static/js/chat-widget.js index dd41bbf..d777a81 100644 --- a/web/static/js/chat-widget.js +++ b/web/static/js/chat-widget.js @@ -3,6 +3,7 @@ // plain-text composer. Reuses the conversations API + live WS channel. import { api } from '/static/js/api.js'; import { subscribe, onPollFallback } from '/static/js/ws.js'; +import { attachMentions } from '/static/js/mention.js'; if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') { let meId = ''; @@ -117,9 +118,12 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') } catch (e) { input.value = text; } } panel.querySelector('#cw-send').addEventListener('click', send); - panel.querySelector('#cw-input').addEventListener('keydown', (e) => { + const cwInput = panel.querySelector('#cw-input'); + cwInput.addEventListener('keydown', (e) => { + if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter if (e.key === 'Enter') { e.preventDefault(); send(); } }); + attachMentions(cwInput); fab.addEventListener('click', async () => { open = !open; diff --git a/web/static/js/mention.js b/web/static/js/mention.js new file mode 100644 index 0000000..b64b643 --- /dev/null +++ b/web/static/js/mention.js @@ -0,0 +1,129 @@ +// @-mention autocomplete. Attaches to a