From 45ee8d0e4b3a7f89dcec4ee48af92f2a2733e132 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 28 Feb 2026 07:13:29 -0500 Subject: [PATCH] Port diligence request model + CSV/XLSX import from old dealroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RequestData and WorkstreamData types to lib/types.go - Add excelize/v2 dependency for XLSX parsing - Add GET /api/projects/{projectID}/requests endpoint (lists requests grouped by section) - Add POST /api/projects/{projectID}/requests/import endpoint with: - Smart header detection (scans first 12 rows for keyword matches) - CSV and XLSX support (detects by extension + magic bytes) - Priority mapping (high/critical/urgent→high, low/nice/optional→low) - Mode: add or replace existing requests - Optional section_filter parameter - Optional create_workstreams=true to create workstreams from sections - Update project.html template: - Requests tab calls /api/projects/{id}/requests - Results grouped by section with collapsible headers - Shows item_number, title, priority badge (colored dot), status badge - Import button opens modal with file upload, mode selector, options --- api/handlers.go | 471 ++++++++++++++++++++++++++++++ api/routes.go | 6 +- go.mod | 11 +- go.sum | 18 ++ lib/types.go | 24 ++ portal/templates/app/project.html | 179 +++++++----- 6 files changed, 632 insertions(+), 77 deletions(-) diff --git a/api/handlers.go b/api/handlers.go index 09cee57..d653b78 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1,6 +1,9 @@ package api import ( + "bufio" + "bytes" + "encoding/csv" "crypto/rand" "encoding/hex" "encoding/json" @@ -17,6 +20,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/mish/dealspace/lib" + "github.com/xuri/excelize/v2" ) // Handlers holds dependencies for HTTP handlers. @@ -1558,3 +1562,470 @@ func (h *Handlers) DeleteDealOrg(w http.ResponseWriter, r *http.Request) { func (h *Handlers) ServeAppOrgs(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "app/orgs.html", nil) } + +// --------------------------------------------------------------------------- +// Request Import/List API endpoints +// --------------------------------------------------------------------------- + +// ListRequests handles GET /api/projects/{projectID}/requests +// Returns all request entries for a project, sorted by section + item_number +func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + + if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + // Get all request entries for this project + rows, err := h.DB.Conn.Query( + `SELECT entry_id, project_id, parent_id, type, depth, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, + version, deleted_at, deleted_by, key_version, + created_at, updated_at, created_by + FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL + ORDER BY created_at ASC`, projectID, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list requests") + return + } + defer rows.Close() + + projectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + + type RequestItem struct { + EntryID string `json:"entry_id"` + ProjectID string `json:"project_id"` + Section string `json:"section"` + ItemNumber string `json:"item_number"` + Title string `json:"title"` + Priority string `json:"priority"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + Data lib.RequestData `json:"data"` + } + + var requests []RequestItem + for rows.Next() { + var e lib.Entry + err := rows.Scan( + &e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth, + &e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage, + &e.AssigneeID, &e.ReturnToID, &e.OriginID, + &e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion, + &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy, + ) + if err != nil { + continue + } + + item := RequestItem{ + EntryID: e.EntryID, + ProjectID: e.ProjectID, + CreatedAt: e.CreatedAt, + } + + // Decrypt data + if len(e.Data) > 0 { + dataText, err := lib.Unpack(projectKey, e.Data) + if err == nil { + var reqData lib.RequestData + if json.Unmarshal([]byte(dataText), &reqData) == nil { + item.Section = reqData.Section + item.ItemNumber = reqData.ItemNumber + item.Title = reqData.Title + item.Priority = reqData.Priority + item.Status = reqData.Status + item.Data = reqData + } + } + } + + requests = append(requests, item) + } + + // Sort by section, then item_number + // (already sorted by created_at from query, which preserves import order) + + if requests == nil { + requests = []RequestItem{} + } + + JSONResponse(w, http.StatusOK, requests) +} + +// ImportRequests handles POST /api/projects/{projectID}/requests/import +// Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams +func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + + if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + // Parse multipart form (max 20MB) + if err := r.ParseMultipartForm(20 << 20); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form data") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required") + return + } + defer file.Close() + + mode := r.FormValue("mode") + if mode == "" { + mode = "add" + } + sectionFilter := r.FormValue("section_filter") + createWorkstreams := r.FormValue("create_workstreams") == "true" + + // Read file into memory + raw, err := io.ReadAll(file) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read file") + return + } + + // Detect file type by extension or magic bytes + fname := strings.ToLower(header.Filename) + isXLSX := strings.HasSuffix(fname, ".xlsx") || strings.HasSuffix(fname, ".xls") || + (len(raw) >= 2 && raw[0] == 'P' && raw[1] == 'K') + + var rows [][]string + + if isXLSX { + xf, err := excelize.OpenReader(bytes.NewReader(raw)) + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_xlsx", "Failed to parse XLSX: "+err.Error()) + return + } + sheetName := xf.GetSheetName(0) + xlRows, err := xf.GetRows(sheetName) + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_xlsx", "Failed to read sheet: "+err.Error()) + return + } + rows = xlRows + } else { + reader := csv.NewReader(bufio.NewReader(bytes.NewReader(raw))) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + csvRows, err := reader.ReadAll() + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_csv", "Failed to parse CSV: "+err.Error()) + return + } + rows = csvRows + } + + // Log first 12 rows for debugging + for ri, row := range rows { + if ri >= 12 { + break + } + log.Printf("[import-debug] row %d: %v", ri, row) + } + + // Smart header detection: scan first 12 rows for keyword matches + idxSection := -1 + idxItem := -1 + idxDesc := -1 + idxPriority := -1 + headerRowIdx := 0 + bestScore := 0 + + for ri, record := range rows { + if ri >= 12 { + break + } + score := 0 + tmpSection, tmpItem, tmpDesc, tmpPri := -1, -1, -1, -1 + for ci, cell := range record { + h := strings.ToLower(strings.TrimSpace(cell)) + if h == "" { + continue + } + if containsAny(h, "section", "category", "topic", "area", "phase", "workstream") { + tmpSection = ci + score += 3 + } else if containsAny(h, "description", "request", "document", "information requested", "detail") { + tmpDesc = ci + score += 3 + } else if containsAny(h, "priority", "urgency", "importance", "criticality") { + tmpPri = ci + score += 2 + } else if h == "#" || h == "no." || h == "no" || h == "item #" || h == "item#" || + containsAny(h, "item no", "ref no", "ref #") { + tmpItem = ci + score += 2 + } + } + if score > bestScore { + bestScore = score + headerRowIdx = ri + if tmpSection >= 0 { + idxSection = tmpSection + } + if tmpItem >= 0 { + idxItem = tmpItem + } + if tmpDesc >= 0 { + idxDesc = tmpDesc + } + if tmpPri >= 0 { + idxPriority = tmpPri + } + } + } + + // Fall back to positional if no header found + if bestScore < 2 { + headerRowIdx = 0 + idxSection = 0 + idxItem = 1 + idxDesc = 2 + } + + // If desc still not found, pick column with longest average text + if idxDesc < 0 && len(rows) > headerRowIdx+1 { + maxLen := 0 + for ci := range rows[headerRowIdx] { + total := 0 + count := 0 + for ri := headerRowIdx + 1; ri < len(rows) && ri < headerRowIdx+20; ri++ { + if ci < len(rows[ri]) { + total += len(strings.TrimSpace(rows[ri][ci])) + count++ + } + } + avg := 0 + if count > 0 { + avg = total / count + } + if avg > maxLen && ci != idxSection && ci != idxItem { + maxLen = avg + idxDesc = ci + } + } + } + + log.Printf("[import-debug] header at row %d (score=%d) | section=%d item=%d desc=%d priority=%d", + headerRowIdx, bestScore, idxSection, idxItem, idxDesc, idxPriority) + + // Parse rows into request items + type reqRow struct { + section, itemNumber, description, priority string + } + var items []reqRow + sections := make(map[string]bool) + + for ri, record := range rows { + if ri <= headerRowIdx { + continue + } + if len(record) == 0 { + continue + } + // Skip blank rows + allBlank := true + for _, c := range record { + if strings.TrimSpace(c) != "" { + allBlank = false + break + } + } + if allBlank { + continue + } + + get := func(idx int) string { + if idx >= 0 && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" + } + + desc := get(idxDesc) + if desc == "" { + continue + } + + section := get(idxSection) + if sectionFilter != "" && !strings.EqualFold(section, sectionFilter) { + continue + } + + priority := "medium" + if idxPriority >= 0 { + p := strings.ToLower(get(idxPriority)) + switch { + case strings.Contains(p, "high") || strings.Contains(p, "critical") || strings.Contains(p, "urgent"): + priority = "high" + case strings.Contains(p, "low") || strings.Contains(p, "nice") || strings.Contains(p, "optional"): + priority = "low" + } + } + + items = append(items, reqRow{ + section: section, + itemNumber: get(idxItem), + description: desc, + priority: priority, + }) + sections[section] = true + } + + if len(items) == 0 { + ErrorResponse(w, http.StatusBadRequest, "no_items", "No valid items found in file") + return + } + + // Handle replace mode: delete existing requests + if mode == "replace" { + _, err := h.DB.Conn.Exec( + `UPDATE entries SET deleted_at = ?, deleted_by = ? + WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL`, + time.Now().UnixMilli(), actorID, projectID, + ) + if err != nil { + log.Printf("Failed to delete existing requests: %v", err) + } + } + + // Insert request entries + projectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed") + return + } + + now := time.Now().UnixMilli() + imported := 0 + skipped := 0 + + for _, item := range items { + entryID := uuid.New().String() + + // Build summary (first 120 chars of title/description) + summary := item.description + if len(summary) > 120 { + summary = summary[:120] + } + + // Build request data + reqData := lib.RequestData{ + Title: item.description, + ItemNumber: item.itemNumber, + Section: item.section, + Description: item.description, + Priority: item.priority, + Status: "open", + } + dataJSON, _ := json.Marshal(reqData) + + // Pack encrypted fields + summaryPacked, err := lib.Pack(projectKey, summary) + if err != nil { + skipped++ + continue + } + dataPacked, err := lib.Pack(projectKey, string(dataJSON)) + if err != nil { + skipped++ + continue + } + + // Insert entry + _, err = h.DB.Conn.Exec( + `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, + version, deleted_at, deleted_by, key_version, + created_at, updated_at, created_by) + VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, + entryID, projectID, projectID, lib.TypeRequest, 1, + nil, nil, summaryPacked, dataPacked, lib.StagePreDataroom, + "", "", "", + 1, nil, nil, 1, + now, now, actorID, + ) + if err != nil { + log.Printf("Failed to insert request: %v", err) + skipped++ + continue + } + imported++ + } + + // Create workstreams if requested + if createWorkstreams { + for section := range sections { + if section == "" { + continue + } + // Check if workstream already exists + var count int + h.DB.Conn.QueryRow( + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'workstream' AND deleted_at IS NULL`, + projectID, + ).Scan(&count) + + // Create workstream entry + wsID := uuid.New().String() + wsData := lib.WorkstreamData{Name: section, Description: ""} + wsDataJSON, _ := json.Marshal(wsData) + wsSummaryPacked, _ := lib.Pack(projectKey, section) + wsDataPacked, _ := lib.Pack(projectKey, string(wsDataJSON)) + + h.DB.Conn.Exec( + `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, + version, deleted_at, deleted_by, key_version, + created_at, updated_at, created_by) + VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, + wsID, projectID, projectID, lib.TypeWorkstream, 1, + nil, nil, wsSummaryPacked, wsDataPacked, lib.StagePreDataroom, + "", "", "", + 1, nil, nil, 1, + now, now, actorID, + ) + } + } + + // Build sections list + sectionList := make([]string, 0, len(sections)) + for s := range sections { + if s != "" { + sectionList = append(sectionList, s) + } + } + + log.Printf("[import] total rows: %d, header row: %d, imported: %d, skipped: %d, sections: %v", + len(rows), headerRowIdx, imported, skipped, sectionList) + + JSONResponse(w, http.StatusOK, map[string]any{ + "imported": imported, + "skipped": skipped, + "sections": sectionList, + }) +} + +// containsAny checks if s contains any of the given substrings +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/api/routes.go b/api/routes.go index 9c894b5..fc1e30b 100644 --- a/api/routes.go +++ b/api/routes.go @@ -60,7 +60,11 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. // Task inbox (per-project) r.Get("/projects/{projectID}/tasks", h.GetMyTasks) - // Requests + // Requests (list and import) + r.Get("/projects/{projectID}/requests", h.ListRequests) + r.Post("/projects/{projectID}/requests/import", h.ImportRequests) + + // Request detail r.Get("/requests/{requestID}", h.GetRequestDetail) // File upload/download diff --git a/go.mod b/go.mod index 2497daf..316a206 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/mattn/go-sqlite3 v1.14.24 github.com/pdfcpu/pdfcpu v0.11.1 - golang.org/x/crypto v0.43.0 + golang.org/x/crypto v0.48.0 ) require ( @@ -18,7 +18,14 @@ require ( github.com/hhrutter/tiff v1.0.2 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.10.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect golang.org/x/image v0.32.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 0772cb5..0890211 100644 --- a/go.sum +++ b/go.sum @@ -20,12 +20,30 @@ github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/lib/types.go b/lib/types.go index e790a88..2818138 100644 --- a/lib/types.go +++ b/lib/types.go @@ -230,3 +230,27 @@ type Config struct { Mailer *Mailer BackdoorCode string // OTP backdoor for dev/testing } + +// RequestData is the JSON structure packed into a request entry's Data field. +// Represents a diligence request item imported from CSV/XLSX. +type RequestData struct { + Title string `json:"title"` // human-readable description + ItemNumber string `json:"item_number"` // e.g. "1.3", "A-12" + Section string `json:"section"` // e.g. "Financial", "Legal" + Description string `json:"description"` // full detail / context + Priority string `json:"priority"` // high | medium | low + Status string `json:"status"` // open | in_progress | answered | not_applicable + AssigneeID string `json:"assignee_id,omitempty"` + AssigneeName string `json:"assignee_name,omitempty"` + DueDate string `json:"due_date,omitempty"` // YYYY-MM-DD + BuyerComment string `json:"buyer_comment,omitempty"` + SellerComment string `json:"seller_comment,omitempty"` + Tags []string `json:"tags,omitempty"` + LinkedEntryIDs []string `json:"linked_entry_ids,omitempty"` // linked answer/file entries +} + +// WorkstreamData is the JSON structure packed into a workstream entry's Data field. +type WorkstreamData struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index a385a5c..43d7c25 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -11,6 +11,15 @@ .tab.active { color: #c9a84c; border-bottom: 2px solid #c9a84c; } .tab { border-bottom: 2px solid transparent; } .req-row:hover { background: rgba(255,255,255,0.03); } + .section-header { cursor: pointer; user-select: none; } + .section-header:hover { background: rgba(255,255,255,0.02); } + .priority-high { background: #ef444420; color: #f87171; } + .priority-medium { background: #f59e0b20; color: #fbbf24; } + .priority-low { background: #22c55e20; color: #4ade80; } + .status-open { background: #3b82f620; color: #60a5fa; } + .status-in_progress { background: #f59e0b20; color: #fbbf24; } + .status-answered { background: #22c55e20; color: #4ade80; } + .status-not_applicable { background: #6b728020; color: #9ca3af; } @@ -46,7 +55,6 @@
-
@@ -55,27 +63,28 @@

- +
+ + +
- -
- -
-
Loading requests...
+
Loading requests...
- - - -
- +