Comprehensive test suite: orgs, requests import, RBAC super_admin, domain validation
New tests added: - lib/types_test.go: OrgData, DealOrgData, RequestData, WorkstreamData JSON marshal tests - lib/rbac_test.go additions: TestSuperAdminBypassesCheckAccess, TestIsSuperAdmin, TestIsSuperAdminRevokedGrant, TestSuperAdminCanGrantAnyRole - api/orgs_test.go: TestCreateOrg, TestCreateOrgEmptyDomains, TestCreateOrgMissingDomains, TestListOrgs, TestSuperAdminCanListAllOrgs, TestGetOrg, TestUpdateOrg, and more - api/requests_test.go: TestImportRequestsCSV, TestImportRequestsXLSX, TestImportSmartHeaderDetection, TestImportModeReplace, TestImportModeAdd, TestListRequests, TestPriorityNormalization, and more - api/integration_test.go additions: TestFullDealWorkflow, TestSuperAdminSeeAllProjects Total: 33 new test functions, all passing
This commit is contained in:
parent
4aa03a1e54
commit
4758bafdb7
|
|
@ -472,3 +472,298 @@ func TestChallengeVerifyFlow(t *testing.T) {
|
|||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// ---- Additional integration tests ----
|
||||
|
||||
func TestFullDealWorkflow(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "dealspace-deal-workflow-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
|
||||
|
||||
cfg := &lib.Config{
|
||||
MasterKey: masterKey,
|
||||
JWTSecret: jwtSecret,
|
||||
Env: "development",
|
||||
BackdoorCode: "220402",
|
||||
}
|
||||
|
||||
tmpDir, _ := os.MkdirTemp("", "dealspace-deal-workflow-store")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
store, _ := lib.NewLocalStore(tmpDir)
|
||||
|
||||
router := NewRouter(db, cfg, store, nil, nil)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Step 1: Create IB admin user
|
||||
t.Log("Step 1: Create IB admin user")
|
||||
setupTestUser(t, db, cfg, "ibadmin@workflow.com", "IB Admin")
|
||||
ibToken := loginWithBackdoor(t, client, server.URL, "ibadmin@workflow.com")
|
||||
|
||||
// Step 2: Create a project
|
||||
t.Log("Step 2: Create project")
|
||||
projectBody, _ := json.Marshal(map[string]string{
|
||||
"name": "Acme Acquisition",
|
||||
"deal_type": "M&A",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create project expected 201, got %d", resp.StatusCode)
|
||||
}
|
||||
var projectResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&projectResp)
|
||||
resp.Body.Close()
|
||||
projectID := projectResp["project_id"].(string)
|
||||
t.Logf("Created project: %s", projectID)
|
||||
|
||||
// Step 3: Create an organization
|
||||
t.Log("Step 3: Create organization")
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Acme Corp",
|
||||
"domains": []string{"acme.com", "acmecorp.com"},
|
||||
"role": "seller",
|
||||
})
|
||||
req, _ = http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = client.Do(req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
t.Fatalf("create org expected 201, got %d: %v", resp.StatusCode, errResp)
|
||||
}
|
||||
var orgResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&orgResp)
|
||||
resp.Body.Close()
|
||||
orgID := orgResp["entry_id"].(string)
|
||||
t.Logf("Created org: %s", orgID)
|
||||
|
||||
// Step 4: Link org to project (deal_org)
|
||||
t.Log("Step 4: Link org to project")
|
||||
dealOrgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"org_id": orgID,
|
||||
"role": "seller",
|
||||
"domain_lock": true,
|
||||
})
|
||||
req, _ = http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/orgs", bytes.NewReader(dealOrgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = client.Do(req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
t.Fatalf("create deal_org expected 201, got %d: %v", resp.StatusCode, errResp)
|
||||
}
|
||||
var dealOrgResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&dealOrgResp)
|
||||
resp.Body.Close()
|
||||
t.Logf("Linked org to project: %v", dealOrgResp["deal_org_id"])
|
||||
|
||||
// Step 5: List deal orgs and verify domain is shown
|
||||
t.Log("Step 5: List deal orgs")
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/orgs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
resp, _ = client.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list deal orgs expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var dealOrgs []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&dealOrgs)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(dealOrgs) != 1 {
|
||||
t.Fatalf("expected 1 deal org, got %d", len(dealOrgs))
|
||||
}
|
||||
|
||||
dealOrg := dealOrgs[0]
|
||||
if dealOrg["org_name"] != "Acme Corp" {
|
||||
t.Errorf("org_name mismatch: got %v", dealOrg["org_name"])
|
||||
}
|
||||
orgDomains, ok := dealOrg["org_domains"].([]interface{})
|
||||
if !ok || len(orgDomains) != 2 {
|
||||
t.Errorf("org_domains should have 2 entries: got %v", dealOrg["org_domains"])
|
||||
}
|
||||
if dealOrg["role"] != "seller" {
|
||||
t.Errorf("role mismatch: got %v", dealOrg["role"])
|
||||
}
|
||||
if dealOrg["domain_lock"] != true {
|
||||
t.Errorf("domain_lock should be true: got %v", dealOrg["domain_lock"])
|
||||
}
|
||||
|
||||
t.Log("Full deal workflow test passed!")
|
||||
}
|
||||
|
||||
func TestSuperAdminSeeAllProjects(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "dealspace-superadmin-projects-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
|
||||
|
||||
cfg := &lib.Config{
|
||||
MasterKey: masterKey,
|
||||
JWTSecret: jwtSecret,
|
||||
Env: "development",
|
||||
BackdoorCode: "220402",
|
||||
}
|
||||
|
||||
tmpDir, _ := os.MkdirTemp("", "dealspace-superadmin-store")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
store, _ := lib.NewLocalStore(tmpDir)
|
||||
|
||||
router := NewRouter(db, cfg, store, nil, nil)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Create User A and their project
|
||||
t.Log("Create User A and Project A")
|
||||
setupTestUser(t, db, cfg, "usera@test.com", "User A")
|
||||
userAToken := loginWithBackdoor(t, client, server.URL, "usera@test.com")
|
||||
|
||||
projectABody, _ := json.Marshal(map[string]string{"name": "Project A"})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectABody))
|
||||
req.Header.Set("Authorization", "Bearer "+userAToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
resp.Body.Close()
|
||||
|
||||
// Create User B and their project
|
||||
t.Log("Create User B and Project B")
|
||||
setupTestUser(t, db, cfg, "userb@test.com", "User B")
|
||||
userBToken := loginWithBackdoor(t, client, server.URL, "userb@test.com")
|
||||
|
||||
projectBBody, _ := json.Marshal(map[string]string{"name": "Project B"})
|
||||
req, _ = http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBBody))
|
||||
req.Header.Set("Authorization", "Bearer "+userBToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = client.Do(req)
|
||||
resp.Body.Close()
|
||||
|
||||
// Create super admin (no explicit project grants except super_admin role)
|
||||
t.Log("Create Super Admin")
|
||||
saID := uuid.New().String()
|
||||
saUser := &lib.User{
|
||||
UserID: saID,
|
||||
Email: "superadmin@test.com",
|
||||
Name: "Super Admin",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, saUser)
|
||||
lib.AccessGrant(db, &lib.Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: "global",
|
||||
UserID: saID,
|
||||
Role: lib.RoleSuperAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
saToken := loginWithBackdoor(t, client, server.URL, "superadmin@test.com")
|
||||
|
||||
// User A should only see Project A
|
||||
t.Log("Verify User A sees only Project A")
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userAToken)
|
||||
resp, _ = client.Do(req)
|
||||
var userAProjects []lib.Entry
|
||||
json.NewDecoder(resp.Body).Decode(&userAProjects)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(userAProjects) != 1 {
|
||||
t.Errorf("User A should see 1 project, got %d", len(userAProjects))
|
||||
}
|
||||
|
||||
// User B should only see Project B
|
||||
t.Log("Verify User B sees only Project B")
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userBToken)
|
||||
resp, _ = client.Do(req)
|
||||
var userBProjects []lib.Entry
|
||||
json.NewDecoder(resp.Body).Decode(&userBProjects)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(userBProjects) != 1 {
|
||||
t.Errorf("User B should see 1 project, got %d", len(userBProjects))
|
||||
}
|
||||
|
||||
// Super admin should see ALL projects (via admin endpoint)
|
||||
t.Log("Verify Super Admin sees all projects")
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/admin/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+saToken)
|
||||
resp, _ = client.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin/projects expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var saProjects []lib.Entry
|
||||
json.NewDecoder(resp.Body).Decode(&saProjects)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(saProjects) < 2 {
|
||||
t.Errorf("Super admin should see at least 2 projects, got %d", len(saProjects))
|
||||
}
|
||||
|
||||
// Verify super admin can access Project A directly (even without explicit grant)
|
||||
t.Log("Verify Super Admin can access Project A detail")
|
||||
// Get project A's ID
|
||||
projectAID := ""
|
||||
for _, p := range saProjects {
|
||||
if p.SummaryText == "Project A" {
|
||||
projectAID = p.ProjectID
|
||||
break
|
||||
}
|
||||
}
|
||||
if projectAID == "" {
|
||||
// Fall back to finding via user A's projects
|
||||
projectAID = userAProjects[0].ProjectID
|
||||
}
|
||||
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectAID+"/detail", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+saToken)
|
||||
resp, _ = client.Do(req)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("super admin should access Project A detail, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
t.Log("Super admin project visibility test passed!")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,617 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mish/dealspace/lib"
|
||||
)
|
||||
|
||||
// testServerSetup creates a test server with all dependencies
|
||||
func testServerSetup(t *testing.T) (*httptest.Server, *lib.DB, *lib.Config, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "dealspace-org-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
|
||||
if err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
|
||||
|
||||
cfg := &lib.Config{
|
||||
MasterKey: masterKey,
|
||||
JWTSecret: jwtSecret,
|
||||
Env: "development",
|
||||
BackdoorCode: "220402",
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dealspace-store-org-test")
|
||||
if err != nil {
|
||||
db.Close()
|
||||
os.Remove(tmpFile.Name())
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, _ := lib.NewLocalStore(tmpDir)
|
||||
|
||||
router := NewRouter(db, cfg, store, nil, nil)
|
||||
server := httptest.NewServer(router)
|
||||
|
||||
cleanup := func() {
|
||||
server.Close()
|
||||
db.Close()
|
||||
os.Remove(tmpFile.Name())
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return server, db, cfg, cleanup
|
||||
}
|
||||
|
||||
// createUserWithRole creates a user and grants them a role on a project
|
||||
func createUserWithRole(t *testing.T, db *lib.DB, email, role, projectID string) string {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
userID := uuid.New().String()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Name: "Test User",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := lib.UserCreate(db, user); err != nil {
|
||||
t.Fatalf("UserCreate: %v", err)
|
||||
}
|
||||
|
||||
if role != "" && projectID != "" {
|
||||
ops := "r"
|
||||
switch role {
|
||||
case lib.RoleSuperAdmin, lib.RoleIBAdmin:
|
||||
ops = "rwdm"
|
||||
case lib.RoleIBMember:
|
||||
ops = "rwdm"
|
||||
case lib.RoleSellerAdmin, lib.RoleSellerMember:
|
||||
ops = "rwd"
|
||||
}
|
||||
|
||||
lib.AccessGrant(db, &lib.Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Ops: ops,
|
||||
CanGrant: role == lib.RoleIBAdmin || role == lib.RoleSuperAdmin,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return userID
|
||||
}
|
||||
|
||||
// createSuperAdmin creates a super admin user
|
||||
func createSuperAdmin(t *testing.T, db *lib.DB, email string) string {
|
||||
t.Helper()
|
||||
now := time.Now().UnixMilli()
|
||||
userID := uuid.New().String()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Name: "Super Admin",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := lib.UserCreate(db, user); err != nil {
|
||||
t.Fatalf("UserCreate: %v", err)
|
||||
}
|
||||
|
||||
lib.AccessGrant(db, &lib.Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: "global",
|
||||
UserID: userID,
|
||||
Role: lib.RoleSuperAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
return userID
|
||||
}
|
||||
|
||||
// getToken gets a token for a user via backdoor auth
|
||||
func getToken(t *testing.T, client *http.Client, serverURL, email string) string {
|
||||
t.Helper()
|
||||
|
||||
// Challenge
|
||||
body, _ := json.Marshal(map[string]string{"email": email})
|
||||
resp, err := client.Post(serverURL+"/api/auth/challenge", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("challenge failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify with backdoor
|
||||
body, _ = json.Marshal(map[string]string{"email": email, "code": "220402"})
|
||||
resp, err = client.Post(serverURL+"/api/auth/verify", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("verify failed: %v", err)
|
||||
}
|
||||
var verifyResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&verifyResp)
|
||||
resp.Body.Close()
|
||||
|
||||
token, ok := verifyResp["token"].(string)
|
||||
if !ok || token == "" {
|
||||
t.Fatalf("no token in verify response: %v", verifyResp)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestCreateOrg(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create an IB admin user (can create orgs)
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin@test.com")
|
||||
|
||||
// Create org with valid payload
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Acme Corporation",
|
||||
"domains": []string{"acme.com", "acme-corp.com"},
|
||||
"role": "seller",
|
||||
"website": "https://acme.com",
|
||||
"description": "A company that makes everything",
|
||||
"contact_name": "Wile E. Coyote",
|
||||
"contact_email": "wile@acme.com",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("create org request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
t.Fatalf("expected 201, got %d: %v", resp.StatusCode, errResp)
|
||||
}
|
||||
|
||||
var createResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&createResp)
|
||||
|
||||
if createResp["entry_id"] == nil || createResp["entry_id"] == "" {
|
||||
t.Error("response should contain entry_id")
|
||||
}
|
||||
if createResp["name"] != "Acme Corporation" {
|
||||
t.Errorf("name mismatch: got %v", createResp["name"])
|
||||
}
|
||||
if createResp["role"] != "seller" {
|
||||
t.Errorf("role mismatch: got %v", createResp["role"])
|
||||
}
|
||||
|
||||
domains, ok := createResp["domains"].([]interface{})
|
||||
if !ok || len(domains) != 2 {
|
||||
t.Errorf("domains should have 2 entries, got %v", createResp["domains"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrgEmptyDomains(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin2@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin2@test.com")
|
||||
|
||||
// Try to create org with empty domains array
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Bad Org",
|
||||
"domains": []string{},
|
||||
"role": "buyer",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for empty domains, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp["code"] != "missing_fields" {
|
||||
t.Errorf("expected missing_fields error code, got %s", errResp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrgMissingDomains(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin3@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin3@test.com")
|
||||
|
||||
// Try to create org without domains field at all
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Missing Domains Org",
|
||||
"role": "seller",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing domains, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrgEmptyDomainString(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin4@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin4@test.com")
|
||||
|
||||
// Try to create org with empty string in domains
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Empty String Domain Org",
|
||||
"domains": []string{"valid.com", ""},
|
||||
"role": "seller",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for empty domain string, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp["code"] != "invalid_domains" {
|
||||
t.Errorf("expected invalid_domains error code, got %s", errResp["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrgs(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create IB admin to create orgs
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin5@test.com", lib.RoleIBAdmin, projectID)
|
||||
ibToken := getToken(t, client, server.URL, "ibadmin5@test.com")
|
||||
|
||||
// Create two orgs
|
||||
for i, name := range []string{"Org Alpha", "Org Beta"} {
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": name,
|
||||
"domains": []string{name + ".com"},
|
||||
"role": "seller",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("failed to create org %d: %d", i, resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Create super_admin to list all orgs (IB admin only sees orgs linked to their projects via deal_org)
|
||||
createSuperAdmin(t, db, "superadmin-list@test.com")
|
||||
saToken := getToken(t, client, server.URL, "superadmin-list@test.com")
|
||||
|
||||
// List orgs as super_admin
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/orgs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+saToken)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list orgs failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orgs []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&orgs)
|
||||
|
||||
if len(orgs) < 2 {
|
||||
t.Errorf("super_admin expected at least 2 orgs, got %d", len(orgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminCanListAllOrgs(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create super admin first
|
||||
createSuperAdmin(t, db, "superadmin@test.com")
|
||||
|
||||
// Create an IB admin to create orgs
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin6@test.com", lib.RoleIBAdmin, projectID)
|
||||
ibToken := getToken(t, client, server.URL, "ibadmin6@test.com")
|
||||
|
||||
// Create org
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "SuperAdmin Test Org",
|
||||
"domains": []string{"supertest.com"},
|
||||
"role": "buyer",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+ibToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
resp.Body.Close()
|
||||
|
||||
// Super admin lists orgs
|
||||
saToken := getToken(t, client, server.URL, "superadmin@test.com")
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/orgs", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+saToken)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list orgs as super admin failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orgs []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&orgs)
|
||||
|
||||
// Super admin should see all orgs
|
||||
found := false
|
||||
for _, org := range orgs {
|
||||
if org["name"] == "SuperAdmin Test Org" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("super admin should see all orgs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrgForbiddenForRegularUser(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create a regular user (buyer role)
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "buyer@test.com", lib.RoleBuyerMember, projectID)
|
||||
token := getToken(t, client, server.URL, "buyer@test.com")
|
||||
|
||||
// Try to create org (should fail - only IB admin or super admin can create orgs)
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Forbidden Org",
|
||||
"domains": []string{"forbidden.com"},
|
||||
"role": "buyer",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for regular user, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrg(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin7@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin7@test.com")
|
||||
|
||||
// Create org
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Get Test Org",
|
||||
"domains": []string{"gettest.com"},
|
||||
"role": "seller",
|
||||
"contact_email": "contact@gettest.com",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
var createResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&createResp)
|
||||
resp.Body.Close()
|
||||
|
||||
orgID := createResp["entry_id"].(string)
|
||||
|
||||
// Get the org
|
||||
req, _ = http.NewRequest("GET", server.URL+"/api/orgs/"+orgID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get org failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var orgResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&orgResp)
|
||||
|
||||
if orgResp["name"] != "Get Test Org" {
|
||||
t.Errorf("name mismatch: got %v", orgResp["name"])
|
||||
}
|
||||
if orgResp["contact_email"] != "contact@gettest.com" {
|
||||
t.Errorf("contact_email mismatch: got %v", orgResp["contact_email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOrg(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Use super_admin for update (org entries use their own ID as project_id, need super_admin access)
|
||||
createSuperAdmin(t, db, "superadmin-update@test.com")
|
||||
token := getToken(t, client, server.URL, "superadmin-update@test.com")
|
||||
|
||||
// Create org
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Update Test Org",
|
||||
"domains": []string{"update.com"},
|
||||
"role": "seller",
|
||||
})
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
var createResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&createResp)
|
||||
resp.Body.Close()
|
||||
|
||||
orgID := createResp["entry_id"].(string)
|
||||
|
||||
// Update the org
|
||||
updateBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Updated Org Name",
|
||||
"domains": []string{"update.com", "updated.com"},
|
||||
"description": "Now with a description",
|
||||
"version": 1,
|
||||
})
|
||||
req, _ = http.NewRequest("PATCH", server.URL+"/api/orgs/"+orgID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("update org failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
t.Fatalf("expected 200, got %d: %v", resp.StatusCode, errResp)
|
||||
}
|
||||
|
||||
var updateResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&updateResp)
|
||||
|
||||
if updateResp["name"] != "Updated Org Name" {
|
||||
t.Errorf("name should be updated: got %v", updateResp["name"])
|
||||
}
|
||||
if updateResp["description"] != "Now with a description" {
|
||||
t.Errorf("description should be updated: got %v", updateResp["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrgInvalidRole(t *testing.T) {
|
||||
server, db, _, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
projectID := uuid.New().String()
|
||||
createUserWithRole(t, db, "ibadmin9@test.com", lib.RoleIBAdmin, projectID)
|
||||
token := getToken(t, client, server.URL, "ibadmin9@test.com")
|
||||
|
||||
// Try to create org with invalid role
|
||||
orgBody, _ := json.Marshal(map[string]interface{}{
|
||||
"name": "Invalid Role Org",
|
||||
"domains": []string{"invalid.com"},
|
||||
"role": "invalid_role",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid role, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var errResp map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||
if errResp["code"] != "invalid_role" {
|
||||
t.Errorf("expected invalid_role error code, got %s", errResp["code"])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,754 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mish/dealspace/lib"
|
||||
)
|
||||
|
||||
// createTestProjectWithAccess creates a project and grants the user IB admin access
|
||||
func createTestProjectWithAccess(t *testing.T, db *lib.DB, cfg *lib.Config, userID string) string {
|
||||
t.Helper()
|
||||
|
||||
projectID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Create project entry
|
||||
key, _ := lib.DeriveProjectKey(cfg.MasterKey, projectID)
|
||||
summary, _ := lib.Pack(key, "Test Import Project")
|
||||
data, _ := lib.Pack(key, `{"name": "Test Import Project"}`)
|
||||
|
||||
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 (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
|
||||
projectID, projectID, "", lib.TypeProject, 0,
|
||||
nil, nil, summary, data, lib.StagePreDataroom,
|
||||
"", "", "",
|
||||
1, nil, nil, 1,
|
||||
now, now, userID,
|
||||
)
|
||||
|
||||
// Grant access
|
||||
lib.AccessGrant(db, &lib.Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
UserID: userID,
|
||||
Role: lib.RoleIBAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
return projectID
|
||||
}
|
||||
|
||||
// uploadFile performs a multipart file upload
|
||||
func uploadFile(t *testing.T, client *http.Client, url, token, filename, contentType string, content []byte, formFields map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
// Add form fields
|
||||
for key, val := range formFields {
|
||||
writer.WriteField(key, val)
|
||||
}
|
||||
|
||||
// Add file
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
part.Write(content)
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", url, &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload request failed: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestImportRequestsCSV(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create user
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "import@test.com",
|
||||
Name: "Import Test",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "import@test.com")
|
||||
|
||||
// Create a valid CSV
|
||||
csvContent := `Section,Item #,Description,Priority
|
||||
Financial,1.1,Provide audited financial statements,High
|
||||
Financial,1.2,Revenue breakdown by product,Medium
|
||||
Legal,2.1,List of all pending litigation,Critical
|
||||
Legal,2.2,Employment contracts for executives,Low
|
||||
HR,3.1,Organization chart,Nice to have`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "requests.csv", "text/csv", []byte(csvContent), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var importResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&importResp)
|
||||
|
||||
imported := int(importResp["imported"].(float64))
|
||||
if imported != 5 {
|
||||
t.Errorf("expected 5 imported, got %d", imported)
|
||||
}
|
||||
|
||||
skipped := int(importResp["skipped"].(float64))
|
||||
if skipped != 0 {
|
||||
t.Errorf("expected 0 skipped, got %d", skipped)
|
||||
}
|
||||
|
||||
sections := importResp["sections"].([]interface{})
|
||||
if len(sections) != 3 {
|
||||
t.Errorf("expected 3 sections, got %d: %v", len(sections), sections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRequestsXLSX(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "xlsx@test.com",
|
||||
Name: "XLSX Test",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "xlsx@test.com")
|
||||
|
||||
// Create a minimal valid XLSX
|
||||
xlsxContent := createMinimalXLSX([][]string{
|
||||
{"Section", "Item #", "Description", "Priority"},
|
||||
{"Finance", "F-1", "Cash flow statement", "High"},
|
||||
{"Finance", "F-2", "Balance sheet", "Medium"},
|
||||
})
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "requests.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
xlsxContent, nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var importResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&importResp)
|
||||
|
||||
imported := int(importResp["imported"].(float64))
|
||||
if imported != 2 {
|
||||
t.Errorf("expected 2 imported, got %d", imported)
|
||||
}
|
||||
}
|
||||
|
||||
// createMinimalXLSX creates a minimal valid XLSX file from rows
|
||||
func createMinimalXLSX(rows [][]string) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// [Content_Types].xml
|
||||
ct := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
|
||||
</Types>`
|
||||
w, _ := zw.Create("[Content_Types].xml")
|
||||
w.Write([]byte(ct))
|
||||
|
||||
// _rels/.rels
|
||||
rels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`
|
||||
w, _ = zw.Create("_rels/.rels")
|
||||
w.Write([]byte(rels))
|
||||
|
||||
// xl/_rels/workbook.xml.rels
|
||||
wbrels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
|
||||
</Relationships>`
|
||||
w, _ = zw.Create("xl/_rels/workbook.xml.rels")
|
||||
w.Write([]byte(wbrels))
|
||||
|
||||
// xl/workbook.xml
|
||||
wb := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
||||
</workbook>`
|
||||
w, _ = zw.Create("xl/workbook.xml")
|
||||
w.Write([]byte(wb))
|
||||
|
||||
// Collect all strings and build shared strings
|
||||
var allStrings []string
|
||||
stringIndex := make(map[string]int)
|
||||
for _, row := range rows {
|
||||
for _, cell := range row {
|
||||
if _, exists := stringIndex[cell]; !exists {
|
||||
stringIndex[cell] = len(allStrings)
|
||||
allStrings = append(allStrings, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xl/sharedStrings.xml
|
||||
ssb := bytes.NewBufferString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="` + fmt.Sprintf("%d", len(allStrings)) + `" uniqueCount="` + fmt.Sprintf("%d", len(allStrings)) + `">`)
|
||||
for _, s := range allStrings {
|
||||
ssb.WriteString("<si><t>" + escapeXML(s) + "</t></si>")
|
||||
}
|
||||
ssb.WriteString("</sst>")
|
||||
w, _ = zw.Create("xl/sharedStrings.xml")
|
||||
w.Write(ssb.Bytes())
|
||||
|
||||
// xl/worksheets/sheet1.xml
|
||||
sheetb := bytes.NewBufferString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>`)
|
||||
for ri, row := range rows {
|
||||
sheetb.WriteString(fmt.Sprintf(`<row r="%d">`, ri+1))
|
||||
for ci, cell := range row {
|
||||
colLetter := string(rune('A' + ci))
|
||||
cellRef := fmt.Sprintf("%s%d", colLetter, ri+1)
|
||||
idx := stringIndex[cell]
|
||||
sheetb.WriteString(fmt.Sprintf(`<c r="%s" t="s"><v>%d</v></c>`, cellRef, idx))
|
||||
}
|
||||
sheetb.WriteString("</row>")
|
||||
}
|
||||
sheetb.WriteString("</sheetData></worksheet>")
|
||||
w, _ = zw.Create("xl/worksheets/sheet1.xml")
|
||||
w.Write(sheetb.Bytes())
|
||||
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
var buf bytes.Buffer
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '<':
|
||||
buf.WriteString("<")
|
||||
case '>':
|
||||
buf.WriteString(">")
|
||||
case '&':
|
||||
buf.WriteString("&")
|
||||
case '"':
|
||||
buf.WriteString(""")
|
||||
case '\'':
|
||||
buf.WriteString("'")
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestImportSmartHeaderDetection(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "smartheader@test.com",
|
||||
Name: "Smart Header",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "smartheader@test.com")
|
||||
|
||||
// CSV with 3 title rows before the actual header
|
||||
csvContent := `Project XYZ Due Diligence Request List
|
||||
Confidential - Internal Use Only
|
||||
Generated: 2026-02-28
|
||||
Section,Item #,Information Requested,Priority
|
||||
Financial,1.1,Audited financials 3 years,High
|
||||
Financial,1.2,Monthly management reports,Medium`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "with-title-rows.csv", "text/csv", []byte(csvContent), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var importResp map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&importResp)
|
||||
|
||||
// Should detect header at row 4 (index 3) and import 2 rows
|
||||
imported := int(importResp["imported"].(float64))
|
||||
if imported != 2 {
|
||||
t.Errorf("expected 2 imported (header detection), got %d", imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportModeReplace(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "replace@test.com",
|
||||
Name: "Replace Test",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "replace@test.com")
|
||||
|
||||
// First import
|
||||
csv1 := `Section,Item #,Description,Priority
|
||||
Finance,1,First batch item 1,High
|
||||
Finance,2,First batch item 2,Medium`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "batch1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
|
||||
resp.Body.Close()
|
||||
|
||||
// Second import with mode=replace
|
||||
csv2 := `Section,Item #,Description,Priority
|
||||
Legal,A,Second batch only,High`
|
||||
|
||||
resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "batch2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "replace"})
|
||||
resp.Body.Close()
|
||||
|
||||
// List requests - should only have 1 (from second batch)
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list requests failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var requests []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&requests)
|
||||
|
||||
if len(requests) != 1 {
|
||||
t.Errorf("after replace, expected 1 request, got %d", len(requests))
|
||||
}
|
||||
if len(requests) > 0 && requests[0]["section"] != "Legal" {
|
||||
t.Errorf("expected Legal section, got %v", requests[0]["section"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportModeAdd(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "addmode@test.com",
|
||||
Name: "Add Mode",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "addmode@test.com")
|
||||
|
||||
// First import
|
||||
csv1 := `Section,Item #,Description,Priority
|
||||
Finance,1,First item,High`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "add1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
|
||||
resp.Body.Close()
|
||||
|
||||
// Second import with mode=add (accumulate)
|
||||
csv2 := `Section,Item #,Description,Priority
|
||||
Legal,2,Second item,Medium`
|
||||
|
||||
resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "add2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "add"})
|
||||
resp.Body.Close()
|
||||
|
||||
// List requests - should have 2 (accumulated)
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list requests failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var requests []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&requests)
|
||||
|
||||
if len(requests) != 2 {
|
||||
t.Errorf("after add mode, expected 2 requests, got %d", len(requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRequests(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "list@test.com",
|
||||
Name: "List Test",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "list@test.com")
|
||||
|
||||
// Import some requests
|
||||
csv := `Section,Item #,Description,Priority
|
||||
Alpha,A-1,First alpha item,High
|
||||
Alpha,A-2,Second alpha item,Medium
|
||||
Beta,B-1,First beta item,Low`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "list.csv", "text/csv", []byte(csv), nil)
|
||||
resp.Body.Close()
|
||||
|
||||
// List requests
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list requests failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var requests []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&requests)
|
||||
|
||||
if len(requests) != 3 {
|
||||
t.Errorf("expected 3 requests, got %d", len(requests))
|
||||
}
|
||||
|
||||
// Verify structure
|
||||
for _, r := range requests {
|
||||
if r["entry_id"] == nil || r["entry_id"] == "" {
|
||||
t.Error("request should have entry_id")
|
||||
}
|
||||
if r["section"] == nil {
|
||||
t.Error("request should have section")
|
||||
}
|
||||
if r["priority"] == nil {
|
||||
t.Error("request should have priority")
|
||||
}
|
||||
if r["status"] == nil {
|
||||
t.Error("request should have status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityNormalization(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "priority@test.com",
|
||||
Name: "Priority Test",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "priority@test.com")
|
||||
|
||||
// CSV with various priority formats
|
||||
csv := `Section,Item #,Description,Priority
|
||||
Test,1,Critical priority,CRITICAL
|
||||
Test,2,Nice to have priority,Nice to have
|
||||
Test,3,Normal priority,Normal
|
||||
Test,4,Urgent priority,Urgent
|
||||
Test,5,High priority,High
|
||||
Test,6,Low priority,Low
|
||||
Test,7,Optional priority,optional`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
token, "priority.csv", "text/csv", []byte(csv), nil)
|
||||
resp.Body.Close()
|
||||
|
||||
// List and verify priority normalization
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list requests failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var requests []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&requests)
|
||||
|
||||
expected := map[string]string{
|
||||
"Critical priority": "high", // CRITICAL → high
|
||||
"Nice to have priority": "low", // Nice to have → low
|
||||
"Normal priority": "medium",
|
||||
"Urgent priority": "high", // Urgent → high
|
||||
"High priority": "high",
|
||||
"Low priority": "low",
|
||||
"Optional priority": "low", // optional → low
|
||||
}
|
||||
|
||||
for _, r := range requests {
|
||||
title := r["title"].(string)
|
||||
if expectedPri, ok := expected[title]; ok {
|
||||
if r["priority"] != expectedPri {
|
||||
t.Errorf("%s: expected priority %q, got %q", title, expectedPri, r["priority"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportNoFile(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
userID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
user := &lib.User{
|
||||
UserID: userID,
|
||||
Email: "nofile@test.com",
|
||||
Name: "No File",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, user)
|
||||
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, userID)
|
||||
token := getToken(t, client, server.URL, "nofile@test.com")
|
||||
|
||||
// Try import without file
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/requests/import", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "multipart/form-data")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing file, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportAccessDenied(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create project owner
|
||||
ownerID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
owner := &lib.User{
|
||||
UserID: ownerID,
|
||||
Email: "owner@test.com",
|
||||
Name: "Owner",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, owner)
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
|
||||
|
||||
// Create buyer (read-only, no write access)
|
||||
buyerID := uuid.New().String()
|
||||
buyer := &lib.User{
|
||||
UserID: buyerID,
|
||||
Email: "buyer-readonly@test.com",
|
||||
Name: "Buyer",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, buyer)
|
||||
lib.AccessGrant(db, &lib.Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
UserID: buyerID,
|
||||
Role: lib.RoleBuyerMember,
|
||||
Ops: "r", // read only
|
||||
CanGrant: false,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
buyerToken := getToken(t, client, server.URL, "buyer-readonly@test.com")
|
||||
|
||||
csv := `Section,Item #,Description,Priority
|
||||
Test,1,Test item,High`
|
||||
|
||||
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
|
||||
buyerToken, "denied.csv", "text/csv", []byte(csv), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for buyer import, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRequestsAccessDenied(t *testing.T) {
|
||||
server, db, cfg, cleanup := testServerSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Create project
|
||||
ownerID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
owner := &lib.User{
|
||||
UserID: ownerID,
|
||||
Email: "listowner@test.com",
|
||||
Name: "List Owner",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, owner)
|
||||
projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
|
||||
|
||||
// Create user with no access
|
||||
noAccessID := uuid.New().String()
|
||||
noAccess := &lib.User{
|
||||
UserID: noAccessID,
|
||||
Email: "noaccess-list@test.com",
|
||||
Name: "No Access",
|
||||
Password: "",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
lib.UserCreate(db, noAccess)
|
||||
|
||||
noAccessToken := getToken(t, client, server.URL, "noaccess-list@test.com")
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+noAccessToken)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for no access list, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// This test file doesn't exist in the original but is mentioned, so skip it
|
||||
func init() {
|
||||
// Ensure tests don't fail due to missing template directory
|
||||
os.MkdirAll("/tmp/dealspace-test-templates", 0755)
|
||||
}
|
||||
264
lib/rbac_test.go
264
lib/rbac_test.go
|
|
@ -385,3 +385,267 @@ func TestWorkstreamAccess(t *testing.T) {
|
|||
t.Error("user should NOT have access to different workstream")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Additional tests for super_admin, IsSuperAdmin, and ValidateOrgDomain ----
|
||||
|
||||
func TestSuperAdminBypassesCheckAccess(t *testing.T) {
|
||||
db, cfg := testDB(t)
|
||||
|
||||
// Create a regular user who owns a project
|
||||
ownerID := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
owner := &User{
|
||||
UserID: ownerID,
|
||||
Email: "owner@test.com",
|
||||
Name: "Owner",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, owner)
|
||||
projectID := testProject(t, db, cfg, ownerID)
|
||||
|
||||
// Create super_admin user (no explicit project access)
|
||||
superAdminID := uuid.New().String()
|
||||
superAdmin := &User{
|
||||
UserID: superAdminID,
|
||||
Email: "superadmin@test.com",
|
||||
Name: "Super Admin",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, superAdmin)
|
||||
|
||||
// Grant super_admin role on a dummy project (super_admin is global but stored per project)
|
||||
dummyProjectID := uuid.New().String()
|
||||
AccessGrant(db, &Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: dummyProjectID,
|
||||
UserID: superAdminID,
|
||||
Role: RoleSuperAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
// Verify super_admin is recognized
|
||||
isSA, err := IsSuperAdmin(db, superAdminID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSuperAdmin failed: %v", err)
|
||||
}
|
||||
if !isSA {
|
||||
t.Error("User with super_admin role should be recognized as super admin")
|
||||
}
|
||||
|
||||
// super_admin should have full access to any project without explicit grant
|
||||
access, err := CheckAccess(db, superAdminID, projectID, "", "r")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin should have read access: %v", err)
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatal("CheckAccess should return an Access object for super_admin")
|
||||
}
|
||||
if access.Role != RoleSuperAdmin {
|
||||
t.Errorf("Access role should be super_admin, got %s", access.Role)
|
||||
}
|
||||
if access.Ops != "rwdm" {
|
||||
t.Errorf("super_admin should have rwdm ops, got %s", access.Ops)
|
||||
}
|
||||
|
||||
// Test all operations
|
||||
_, err = CheckAccess(db, superAdminID, projectID, "", "w")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin should have write access: %v", err)
|
||||
}
|
||||
|
||||
_, err = CheckAccess(db, superAdminID, projectID, "", "d")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin should have delete access: %v", err)
|
||||
}
|
||||
|
||||
_, err = CheckAccess(db, superAdminID, projectID, "", "m")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin should have manage access: %v", err)
|
||||
}
|
||||
|
||||
// Test convenience functions
|
||||
err = CheckAccessRead(db, superAdminID, projectID, "")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin CheckAccessRead should pass: %v", err)
|
||||
}
|
||||
|
||||
err = CheckAccessWrite(db, superAdminID, projectID, "")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin CheckAccessWrite should pass: %v", err)
|
||||
}
|
||||
|
||||
err = CheckAccessDelete(db, superAdminID, projectID, "")
|
||||
if err != nil {
|
||||
t.Errorf("super_admin CheckAccessDelete should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSuperAdmin(t *testing.T) {
|
||||
db, _ := testDB(t)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Create regular user
|
||||
regularID := uuid.New().String()
|
||||
regular := &User{
|
||||
UserID: regularID,
|
||||
Email: "regular@test.com",
|
||||
Name: "Regular User",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, regular)
|
||||
|
||||
// Create super admin user
|
||||
superID := uuid.New().String()
|
||||
superUser := &User{
|
||||
UserID: superID,
|
||||
Email: "super@test.com",
|
||||
Name: "Super User",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, superUser)
|
||||
|
||||
// Grant super_admin role
|
||||
AccessGrant(db, &Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: "any-project",
|
||||
UserID: superID,
|
||||
Role: RoleSuperAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
// Test IsSuperAdmin
|
||||
isSA, err := IsSuperAdmin(db, superID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSuperAdmin failed: %v", err)
|
||||
}
|
||||
if !isSA {
|
||||
t.Error("User with super_admin role should return true")
|
||||
}
|
||||
|
||||
isRegularSA, err := IsSuperAdmin(db, regularID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSuperAdmin for regular user failed: %v", err)
|
||||
}
|
||||
if isRegularSA {
|
||||
t.Error("Regular user should not be super admin")
|
||||
}
|
||||
|
||||
// Test with ib_admin (should NOT be super admin)
|
||||
ibAdminID := uuid.New().String()
|
||||
ibAdmin := &User{
|
||||
UserID: ibAdminID,
|
||||
Email: "ibadmin@test.com",
|
||||
Name: "IB Admin",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, ibAdmin)
|
||||
AccessGrant(db, &Access{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: "test-project",
|
||||
UserID: ibAdminID,
|
||||
Role: RoleIBAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
isIBSA, err := IsSuperAdmin(db, ibAdminID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSuperAdmin for ib_admin failed: %v", err)
|
||||
}
|
||||
if isIBSA {
|
||||
t.Error("ib_admin should NOT be super admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSuperAdminRevokedGrant(t *testing.T) {
|
||||
db, _ := testDB(t)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Create user
|
||||
userID := uuid.New().String()
|
||||
user := &User{
|
||||
UserID: userID,
|
||||
Email: "revoke-test@test.com",
|
||||
Name: "Revoke Test",
|
||||
Password: "$2a$10$test",
|
||||
Active: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
UserCreate(db, user)
|
||||
|
||||
// Grant and then revoke super_admin
|
||||
accessID := uuid.New().String()
|
||||
AccessGrant(db, &Access{
|
||||
ID: accessID,
|
||||
ProjectID: "any-project",
|
||||
UserID: userID,
|
||||
Role: RoleSuperAdmin,
|
||||
Ops: "rwdm",
|
||||
CanGrant: true,
|
||||
GrantedBy: "system",
|
||||
GrantedAt: now,
|
||||
})
|
||||
|
||||
// Should be super admin
|
||||
isSA, _ := IsSuperAdmin(db, userID)
|
||||
if !isSA {
|
||||
t.Error("Should be super admin before revocation")
|
||||
}
|
||||
|
||||
// Revoke the grant
|
||||
AccessRevoke(db, accessID, "system")
|
||||
|
||||
// Should no longer be super admin
|
||||
isSA, _ = IsSuperAdmin(db, userID)
|
||||
if isSA {
|
||||
t.Error("Should NOT be super admin after revocation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminCanGrantAnyRole(t *testing.T) {
|
||||
// super_admin (level 200) should be able to grant any role
|
||||
tests := []struct {
|
||||
target string
|
||||
canDo bool
|
||||
}{
|
||||
{RoleSuperAdmin, true},
|
||||
{RoleIBAdmin, true},
|
||||
{RoleIBMember, true},
|
||||
{RoleSellerAdmin, true},
|
||||
{RoleSellerMember, true},
|
||||
{RoleBuyerAdmin, true},
|
||||
{RoleBuyerMember, true},
|
||||
{RoleObserver, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := CanGrantRole(RoleSuperAdmin, tc.target)
|
||||
if result != tc.canDo {
|
||||
t.Errorf("CanGrantRole(super_admin, %s) = %v, want %v", tc.target, result, tc.canDo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrgDataJSONMarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orgData OrgData
|
||||
}{
|
||||
{
|
||||
name: "single domain",
|
||||
orgData: OrgData{
|
||||
Name: "Kaseya Corp",
|
||||
Domains: []string{"kaseya.com"},
|
||||
Role: "seller",
|
||||
Website: "https://kaseya.com",
|
||||
Description: "IT management software company",
|
||||
ContactName: "Fred Voccola",
|
||||
ContactEmail: "fred@kaseya.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi domain",
|
||||
orgData: OrgData{
|
||||
Name: "Datto Holdings",
|
||||
Domains: []string{"kaseya.com", "datto.com", "unitrends.com"},
|
||||
Role: "buyer",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal",
|
||||
orgData: OrgData{
|
||||
Name: "Test Org",
|
||||
Domains: []string{"test.com"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ib role",
|
||||
orgData: OrgData{
|
||||
Name: "Goldman Sachs",
|
||||
Domains: []string{"gs.com", "goldmansachs.com"},
|
||||
Role: "ib",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advisor role",
|
||||
orgData: OrgData{
|
||||
Name: "Deloitte",
|
||||
Domains: []string{"deloitte.com"},
|
||||
Role: "advisor",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Marshal
|
||||
data, err := json.Marshal(tc.orgData)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var got OrgData
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if got.Name != tc.orgData.Name {
|
||||
t.Errorf("Name: got %q, want %q", got.Name, tc.orgData.Name)
|
||||
}
|
||||
if len(got.Domains) != len(tc.orgData.Domains) {
|
||||
t.Errorf("Domains length: got %d, want %d", len(got.Domains), len(tc.orgData.Domains))
|
||||
}
|
||||
for i, d := range tc.orgData.Domains {
|
||||
if got.Domains[i] != d {
|
||||
t.Errorf("Domains[%d]: got %q, want %q", i, got.Domains[i], d)
|
||||
}
|
||||
}
|
||||
if got.Role != tc.orgData.Role {
|
||||
t.Errorf("Role: got %q, want %q", got.Role, tc.orgData.Role)
|
||||
}
|
||||
if got.Website != tc.orgData.Website {
|
||||
t.Errorf("Website: got %q, want %q", got.Website, tc.orgData.Website)
|
||||
}
|
||||
if got.Description != tc.orgData.Description {
|
||||
t.Errorf("Description: got %q, want %q", got.Description, tc.orgData.Description)
|
||||
}
|
||||
if got.ContactName != tc.orgData.ContactName {
|
||||
t.Errorf("ContactName: got %q, want %q", got.ContactName, tc.orgData.ContactName)
|
||||
}
|
||||
if got.ContactEmail != tc.orgData.ContactEmail {
|
||||
t.Errorf("ContactEmail: got %q, want %q", got.ContactEmail, tc.orgData.ContactEmail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgDataJSONOmitEmpty(t *testing.T) {
|
||||
// Verify omitempty works for optional fields
|
||||
orgData := OrgData{
|
||||
Name: "Minimal Org",
|
||||
Domains: []string{"minimal.com"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(orgData)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Parse as map to check field presence
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("Unmarshal to map failed: %v", err)
|
||||
}
|
||||
|
||||
// Required fields should be present
|
||||
if _, ok := m["name"]; !ok {
|
||||
t.Error("name should be present")
|
||||
}
|
||||
if _, ok := m["domains"]; !ok {
|
||||
t.Error("domains should be present")
|
||||
}
|
||||
|
||||
// Optional fields with zero values should be omitted
|
||||
if v, ok := m["website"]; ok && v != "" {
|
||||
t.Error("website should be omitted when empty")
|
||||
}
|
||||
if v, ok := m["description"]; ok && v != "" {
|
||||
t.Error("description should be omitted when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealOrgDataJSONMarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dealOrgData DealOrgData
|
||||
}{
|
||||
{
|
||||
name: "seller with domain lock",
|
||||
dealOrgData: DealOrgData{
|
||||
OrgID: "org-123",
|
||||
Role: "seller",
|
||||
DomainLock: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "buyer without domain lock",
|
||||
dealOrgData: DealOrgData{
|
||||
OrgID: "org-456",
|
||||
Role: "buyer",
|
||||
DomainLock: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ib role",
|
||||
dealOrgData: DealOrgData{
|
||||
OrgID: "org-789",
|
||||
Role: "ib",
|
||||
DomainLock: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tc.dealOrgData)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var got DealOrgData
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if got.OrgID != tc.dealOrgData.OrgID {
|
||||
t.Errorf("OrgID: got %q, want %q", got.OrgID, tc.dealOrgData.OrgID)
|
||||
}
|
||||
if got.Role != tc.dealOrgData.Role {
|
||||
t.Errorf("Role: got %q, want %q", got.Role, tc.dealOrgData.Role)
|
||||
}
|
||||
if got.DomainLock != tc.dealOrgData.DomainLock {
|
||||
t.Errorf("DomainLock: got %v, want %v", got.DomainLock, tc.dealOrgData.DomainLock)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDataJSONMarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req RequestData
|
||||
}{
|
||||
{
|
||||
name: "full request",
|
||||
req: RequestData{
|
||||
Title: "Provide audited financial statements for the last 3 years",
|
||||
ItemNumber: "1.3",
|
||||
Section: "Financial",
|
||||
Description: "Please provide audited P&L, balance sheet, and cash flow statements for FY2023, FY2024, FY2025.",
|
||||
Priority: "high",
|
||||
Status: "open",
|
||||
AssigneeID: "user-123",
|
||||
AssigneeName: "John Smith",
|
||||
DueDate: "2026-03-15",
|
||||
BuyerComment: "Need by end of week",
|
||||
SellerComment: "Working on it",
|
||||
Tags: []string{"financial", "urgent", "audit"},
|
||||
LinkedEntryIDs: []string{"entry-1", "entry-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal request",
|
||||
req: RequestData{
|
||||
Title: "Basic info request",
|
||||
ItemNumber: "A-1",
|
||||
Section: "General",
|
||||
Priority: "medium",
|
||||
Status: "open",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "answered request",
|
||||
req: RequestData{
|
||||
Title: "Revenue breakdown by geography",
|
||||
ItemNumber: "2.1",
|
||||
Section: "Sales",
|
||||
Description: "Provide revenue split by region (NA, EMEA, APAC)",
|
||||
Priority: "low",
|
||||
Status: "answered",
|
||||
LinkedEntryIDs: []string{"answer-123"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not applicable",
|
||||
req: RequestData{
|
||||
Title: "Union contracts",
|
||||
ItemNumber: "5.2",
|
||||
Section: "HR",
|
||||
Description: "Provide all union agreements",
|
||||
Priority: "low",
|
||||
Status: "not_applicable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tc.req)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var got RequestData
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Title != tc.req.Title {
|
||||
t.Errorf("Title: got %q, want %q", got.Title, tc.req.Title)
|
||||
}
|
||||
if got.ItemNumber != tc.req.ItemNumber {
|
||||
t.Errorf("ItemNumber: got %q, want %q", got.ItemNumber, tc.req.ItemNumber)
|
||||
}
|
||||
if got.Section != tc.req.Section {
|
||||
t.Errorf("Section: got %q, want %q", got.Section, tc.req.Section)
|
||||
}
|
||||
if got.Priority != tc.req.Priority {
|
||||
t.Errorf("Priority: got %q, want %q", got.Priority, tc.req.Priority)
|
||||
}
|
||||
if got.Status != tc.req.Status {
|
||||
t.Errorf("Status: got %q, want %q", got.Status, tc.req.Status)
|
||||
}
|
||||
if len(got.Tags) != len(tc.req.Tags) {
|
||||
t.Errorf("Tags length: got %d, want %d", len(got.Tags), len(tc.req.Tags))
|
||||
}
|
||||
if len(got.LinkedEntryIDs) != len(tc.req.LinkedEntryIDs) {
|
||||
t.Errorf("LinkedEntryIDs length: got %d, want %d", len(got.LinkedEntryIDs), len(tc.req.LinkedEntryIDs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstreamDataJSONMarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ws WorkstreamData
|
||||
}{
|
||||
{
|
||||
name: "with description",
|
||||
ws: WorkstreamData{
|
||||
Name: "Financial Due Diligence",
|
||||
Description: "All financial information requests",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal",
|
||||
ws: WorkstreamData{
|
||||
Name: "Legal",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(tc.ws)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var got WorkstreamData
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Name != tc.ws.Name {
|
||||
t.Errorf("Name: got %q, want %q", got.Name, tc.ws.Name)
|
||||
}
|
||||
if got.Description != tc.ws.Description {
|
||||
t.Errorf("Description: got %q, want %q", got.Description, tc.ws.Description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleHierarchyContainsSuperAdmin(t *testing.T) {
|
||||
// Verify super_admin is in the hierarchy and has the highest level
|
||||
saLevel, ok := RoleHierarchy[RoleSuperAdmin]
|
||||
if !ok {
|
||||
t.Fatal("super_admin should be in RoleHierarchy")
|
||||
}
|
||||
|
||||
// super_admin should be higher than all other roles
|
||||
for role, level := range RoleHierarchy {
|
||||
if role != RoleSuperAdmin && level >= saLevel {
|
||||
t.Errorf("super_admin (%d) should be higher than %s (%d)", saLevel, role, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{{define "answer_approved.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your answer was approved ✓</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<!-- Success Icon -->
|
||||
<div style="text-align: center; margin-bottom: 25px;">
|
||||
<div style="display: inline-block; width: 60px; height: 60px; background-color: #dcfce7; border-radius: 50%; line-height: 60px; font-size: 28px;">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600; text-align: center;">
|
||||
Your answer was approved
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Great news! Your answer for <strong>{{.RequestTitle}}</strong> has been approved.
|
||||
</p>
|
||||
|
||||
{{if .Published}}
|
||||
<div style="background-color: #dcfce7; border-radius: 6px; padding: 15px 20px; margin: 25px 0;">
|
||||
<p style="margin: 0; color: #166534; font-size: 14px;">
|
||||
📁 <strong>Published to Data Room</strong> — Your response is now visible to authorized buyers.
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .DataRoomURL}}
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.DataRoomURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View in Data Room</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
|
||||
Thank you for your prompt response. Keep up the excellent work!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
{{define "answer_rejected.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your answer needs revision</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||
Your answer needs revision
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Your answer for <strong>{{.RequestTitle}}</strong> requires some changes before it can be approved.
|
||||
</p>
|
||||
|
||||
<!-- Feedback Box -->
|
||||
{{if .Reason}}
|
||||
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 20px; margin: 25px 0;">
|
||||
<p style="margin: 0 0 8px 0; color: #991b1b; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;">Feedback</p>
|
||||
<p style="margin: 0; color: #7f1d1d; font-size: 15px; line-height: 1.5;">{{.Reason}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Feedback</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
|
||||
Please update your response based on the feedback above. If you have any questions, you can reply directly in the request thread.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
{{define "answer_submitted.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.AnswererName}} submitted an answer for: {{.RequestTitle}}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<div style="display: inline-block; background-color: #fef3c7; color: #92400e; padding: 6px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 20px;">
|
||||
ACTION REQUIRED
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||
New answer submitted for review
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
<strong>{{.AnswererName}}</strong> has submitted an answer that needs your review.
|
||||
</p>
|
||||
|
||||
<!-- Request Details -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
|
||||
<p style="margin: 0 0 15px 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
|
||||
|
||||
{{if .WorkstreamName}}
|
||||
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Workstream</p>
|
||||
<p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px;">{{.WorkstreamName}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .AnswerPreview}}
|
||||
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Preview</p>
|
||||
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5; font-style: italic;">"{{truncate .AnswerPreview 200}}"</p>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.ReviewURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Review Answer</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||
Once approved, this answer will be published to the data room and visible to authorized buyers.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
{{define "invite.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're invited to {{.ProjectName}}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||
You've been invited to join {{.ProjectName}}
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
<strong>{{.InviterName}}</strong> from <strong>{{.InviterOrg}}</strong> has invited you to join the due diligence process for <strong>{{.ProjectName}}</strong>.
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.InviteURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Accept Invitation</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- What is Dealspace -->
|
||||
<div style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 30px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1a2744; font-size: 14px; font-weight: 600;">What is Dealspace?</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">
|
||||
Dealspace is a secure platform for managing M&A due diligence. All documents are encrypted and watermarked. You control what gets shared and when.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||
⏱ This invitation expires in {{if .ExpiresIn}}{{.ExpiresIn}}{{else}}7 days{{end}}.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{{define "request_forwarded.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.SenderName}} forwarded a request to you</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||
Request forwarded to you
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
<strong>{{.SenderName}}</strong> has forwarded a request to you for your input.
|
||||
</p>
|
||||
|
||||
<!-- Request Details -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
|
||||
<p style="margin: 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
|
||||
|
||||
{{if .HasDueDate}}
|
||||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0 0 4px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Due Date</p>
|
||||
<p style="margin: 0; color: #dc2626; font-size: 14px; font-weight: 500;">{{.DueDate}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Request</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
|
||||
You can respond to this request directly in Dealspace. Your response will be routed back to {{.SenderName}} for review.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
{{define "tasks_assigned.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
|
||||
You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}
|
||||
</h2>
|
||||
|
||||
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
The following request{{if gt .Count 1}}s have{{else}} has{{end}} been assigned to you:
|
||||
</p>
|
||||
|
||||
<!-- Task List -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 0 0 25px 0;">
|
||||
{{range $i, $task := .Tasks}}
|
||||
{{if lt $i 5}}
|
||||
<tr>
|
||||
<td style="padding: 12px 15px; border-left: 3px solid {{if eq $task.Priority "high"}}#dc2626{{else}}#c9a227{{end}}; background-color: #f8f9fa; margin-bottom: 8px;">
|
||||
<p style="margin: 0; color: #1a2744; font-size: 15px; font-weight: 500;">{{$task.Title}}</p>
|
||||
{{if $task.DueDate}}
|
||||
<p style="margin: 4px 0 0 0; color: #6b7280; font-size: 13px;">Due: {{$task.DueDate}}{{if eq $task.Priority "high"}} · <span style="color: #dc2626;">High Priority</span>{{end}}</p>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td style="height: 8px;"></td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
{{if gt .Count 5}}
|
||||
<p style="margin: 0 0 25px 0; color: #6b7280; font-size: 14px;">
|
||||
...and {{sub .Count 5}} more
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View My Tasks</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
|
||||
You're receiving this because you're assigned to requests in {{.ProjectName}}.
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">
|
||||
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Manage Notifications</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Unsubscribe</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{{define "welcome.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Dealspace</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 24px; font-weight: 600;">
|
||||
Welcome to Dealspace{{if .RecipientName}}, {{.RecipientName}}{{end}}! 🎉
|
||||
</h2>
|
||||
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
You're all set up and ready to go. Here are three quick tips to get you started:
|
||||
</p>
|
||||
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📥 Your inbox is your home</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Everything assigned to you appears in your task inbox. Start there each day to see what needs your attention.</p>
|
||||
</div>
|
||||
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📋 Requests, not folders</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">We organize by requests, not file folders. Each request tracks its own status, comments, and documents — all in one place.</p>
|
||||
</div>
|
||||
<div style="margin: 0 0 25px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">🤖 Ask Aria</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Not sure where something is? Ask Aria, our AI assistant. She can find documents, answer questions, and guide you through the process.</p>
|
||||
</div>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Go to My Tasks</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">We're excited to have you on board. Let's make this deal happen! 🚀</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">Need help? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a></p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,744 @@
|
|||
/**
|
||||
* Dealspace Theme System
|
||||
*
|
||||
* All colors are CSS custom properties. Zero hardcoded values in templates.
|
||||
* Switch themes by changing the class on <html>: theme-light, theme-dark, theme-contrast
|
||||
* Per-project overrides use data-project attribute.
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
BASE TOKENS (shared across themes)
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem; /* 4px */
|
||||
--space-sm: 0.5rem; /* 8px */
|
||||
--space-md: 1rem; /* 16px */
|
||||
--space-lg: 1.5rem; /* 24px */
|
||||
--space-xl: 2rem; /* 32px */
|
||||
--space-2xl: 3rem; /* 48px */
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
|
||||
/* Z-index scale */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
LIGHT THEME (default)
|
||||
========================================================================== */
|
||||
|
||||
.theme-light,
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f9fafb;
|
||||
--color-bg-tertiary: #f3f4f6;
|
||||
--color-bg-inverse: #1f2937;
|
||||
--color-bg-hover: #f3f4f6;
|
||||
--color-bg-selected: #eff6ff;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-text-inverse: #ffffff;
|
||||
--color-text-link: #2563eb;
|
||||
--color-text-link-hover: #1d4ed8;
|
||||
|
||||
/* Borders */
|
||||
--color-border-primary: #e5e7eb;
|
||||
--color-border-secondary: #f3f4f6;
|
||||
--color-border-focus: #2563eb;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-hover: #1d4ed8;
|
||||
--color-accent-light: #dbeafe;
|
||||
--color-success: #059669;
|
||||
--color-success-light: #d1fae5;
|
||||
--color-warning: #d97706;
|
||||
--color-warning-light: #fef3c7;
|
||||
--color-error: #dc2626;
|
||||
--color-error-light: #fee2e2;
|
||||
--color-info: #0284c7;
|
||||
--color-info-light: #e0f2fe;
|
||||
|
||||
/* Priority indicators */
|
||||
--color-priority-high: #dc2626;
|
||||
--color-priority-normal: #d97706;
|
||||
--color-priority-low: #9ca3af;
|
||||
|
||||
/* Status badges */
|
||||
--color-status-open: #6b7280;
|
||||
--color-status-open-bg: #f3f4f6;
|
||||
--color-status-assigned: #2563eb;
|
||||
--color-status-assigned-bg: #dbeafe;
|
||||
--color-status-answered: #d97706;
|
||||
--color-status-answered-bg: #fef3c7;
|
||||
--color-status-vetted: #7c3aed;
|
||||
--color-status-vetted-bg: #ede9fe;
|
||||
--color-status-published: #059669;
|
||||
--color-status-published-bg: #d1fae5;
|
||||
--color-status-closed: #9ca3af;
|
||||
--color-status-closed-bg: #f3f4f6;
|
||||
|
||||
/* Components */
|
||||
--color-header-bg: #ffffff;
|
||||
--color-header-text: #111827;
|
||||
--color-header-border: #e5e7eb;
|
||||
--color-card-bg: #ffffff;
|
||||
--color-card-border: #e5e7eb;
|
||||
--color-card-shadow: rgba(0, 0, 0, 0.05);
|
||||
--color-input-bg: #ffffff;
|
||||
--color-input-border: #d1d5db;
|
||||
--color-input-focus: #2563eb;
|
||||
--color-input-placeholder: #9ca3af;
|
||||
--color-badge-bg: #ef4444;
|
||||
--color-badge-text: #ffffff;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-focus: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
DARK THEME
|
||||
========================================================================== */
|
||||
|
||||
.theme-dark {
|
||||
/* Surfaces */
|
||||
--color-bg-primary: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
--color-bg-inverse: #f9fafb;
|
||||
--color-bg-hover: #374151;
|
||||
--color-bg-selected: #1e3a5f;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-text-inverse: #111827;
|
||||
--color-text-link: #60a5fa;
|
||||
--color-text-link-hover: #93c5fd;
|
||||
|
||||
/* Borders */
|
||||
--color-border-primary: #374151;
|
||||
--color-border-secondary: #4b5563;
|
||||
--color-border-focus: #60a5fa;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-hover: #60a5fa;
|
||||
--color-accent-light: #1e3a5f;
|
||||
--color-success: #10b981;
|
||||
--color-success-light: #064e3b;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #78350f;
|
||||
--color-error: #ef4444;
|
||||
--color-error-light: #7f1d1d;
|
||||
--color-info: #0ea5e9;
|
||||
--color-info-light: #0c4a6e;
|
||||
|
||||
/* Priority indicators */
|
||||
--color-priority-high: #ef4444;
|
||||
--color-priority-normal: #f59e0b;
|
||||
--color-priority-low: #6b7280;
|
||||
|
||||
/* Status badges */
|
||||
--color-status-open: #9ca3af;
|
||||
--color-status-open-bg: #374151;
|
||||
--color-status-assigned: #60a5fa;
|
||||
--color-status-assigned-bg: #1e3a5f;
|
||||
--color-status-answered: #f59e0b;
|
||||
--color-status-answered-bg: #78350f;
|
||||
--color-status-vetted: #a78bfa;
|
||||
--color-status-vetted-bg: #4c1d95;
|
||||
--color-status-published: #10b981;
|
||||
--color-status-published-bg: #064e3b;
|
||||
--color-status-closed: #6b7280;
|
||||
--color-status-closed-bg: #374151;
|
||||
|
||||
/* Components */
|
||||
--color-header-bg: #1f2937;
|
||||
--color-header-text: #f9fafb;
|
||||
--color-header-border: #374151;
|
||||
--color-card-bg: #1f2937;
|
||||
--color-card-border: #374151;
|
||||
--color-card-shadow: rgba(0, 0, 0, 0.3);
|
||||
--color-input-bg: #374151;
|
||||
--color-input-border: #4b5563;
|
||||
--color-input-focus: #60a5fa;
|
||||
--color-input-placeholder: #6b7280;
|
||||
--color-badge-bg: #ef4444;
|
||||
--color-badge-text: #ffffff;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
HIGH CONTRAST THEME
|
||||
========================================================================== */
|
||||
|
||||
.theme-contrast {
|
||||
/* Surfaces */
|
||||
--color-bg-primary: #000000;
|
||||
--color-bg-secondary: #1a1a1a;
|
||||
--color-bg-tertiary: #333333;
|
||||
--color-bg-inverse: #ffffff;
|
||||
--color-bg-hover: #333333;
|
||||
--color-bg-selected: #003366;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e0e0e0;
|
||||
--color-text-tertiary: #b0b0b0;
|
||||
--color-text-inverse: #000000;
|
||||
--color-text-link: #66b3ff;
|
||||
--color-text-link-hover: #99ccff;
|
||||
|
||||
/* Borders */
|
||||
--color-border-primary: #ffffff;
|
||||
--color-border-secondary: #808080;
|
||||
--color-border-focus: #ffff00;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-accent: #66b3ff;
|
||||
--color-accent-hover: #99ccff;
|
||||
--color-accent-light: #003366;
|
||||
--color-success: #00ff00;
|
||||
--color-success-light: #003300;
|
||||
--color-warning: #ffff00;
|
||||
--color-warning-light: #333300;
|
||||
--color-error: #ff3333;
|
||||
--color-error-light: #330000;
|
||||
--color-info: #00ffff;
|
||||
--color-info-light: #003333;
|
||||
|
||||
/* Priority indicators */
|
||||
--color-priority-high: #ff3333;
|
||||
--color-priority-normal: #ffff00;
|
||||
--color-priority-low: #808080;
|
||||
|
||||
/* Status badges */
|
||||
--color-status-open: #ffffff;
|
||||
--color-status-open-bg: #333333;
|
||||
--color-status-assigned: #66b3ff;
|
||||
--color-status-assigned-bg: #003366;
|
||||
--color-status-answered: #ffff00;
|
||||
--color-status-answered-bg: #333300;
|
||||
--color-status-vetted: #cc99ff;
|
||||
--color-status-vetted-bg: #330066;
|
||||
--color-status-published: #00ff00;
|
||||
--color-status-published-bg: #003300;
|
||||
--color-status-closed: #808080;
|
||||
--color-status-closed-bg: #1a1a1a;
|
||||
|
||||
/* Components */
|
||||
--color-header-bg: #000000;
|
||||
--color-header-text: #ffffff;
|
||||
--color-header-border: #ffffff;
|
||||
--color-card-bg: #1a1a1a;
|
||||
--color-card-border: #ffffff;
|
||||
--color-card-shadow: none;
|
||||
--color-input-bg: #1a1a1a;
|
||||
--color-input-border: #ffffff;
|
||||
--color-input-focus: #ffff00;
|
||||
--color-input-placeholder: #808080;
|
||||
--color-badge-bg: #ff3333;
|
||||
--color-badge-text: #ffffff;
|
||||
|
||||
/* Shadows - disabled for clarity */
|
||||
--shadow-sm: none;
|
||||
--shadow-md: none;
|
||||
--shadow-lg: none;
|
||||
--shadow-focus: 0 0 0 3px #ffff00;
|
||||
}
|
||||
|
||||
/* High contrast specific: underline all links */
|
||||
.theme-contrast a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.theme-contrast a:focus {
|
||||
outline: 3px solid var(--color-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
PER-PROJECT BRAND OVERRIDES
|
||||
========================================================================== */
|
||||
|
||||
/* Project overrides cascade on top of the selected theme */
|
||||
/* Example: data-project="alpha" */
|
||||
|
||||
[data-project="alpha"] {
|
||||
--color-accent: #7c3aed; /* Purple accent */
|
||||
--color-accent-hover: #6d28d9;
|
||||
--color-header-bg: #7c3aed;
|
||||
--color-header-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-project="beta"] {
|
||||
--color-accent: #059669; /* Emerald accent */
|
||||
--color-accent-hover: #047857;
|
||||
--color-header-bg: #059669;
|
||||
--color-header-text: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
COMPONENT STYLES (using theme variables)
|
||||
========================================================================== */
|
||||
|
||||
/* Base reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.global-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--color-header-bg);
|
||||
border-bottom: 1px solid var(--color-header-border);
|
||||
}
|
||||
|
||||
.global-header .logo {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-header-text);
|
||||
}
|
||||
|
||||
/* Project selector */
|
||||
.project-select {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-input-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
/* Workstream tabs */
|
||||
.workstream-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
padding: 0 var(--space-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.workstream-tabs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.workstream-tabs a:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workstream-tabs a.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-badge-text);
|
||||
background: var(--color-badge-bg);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Task card */
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-card-priority {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.task-card-priority.high { background: var(--color-priority-high); }
|
||||
.task-card-priority.normal { background: var(--color-priority-normal); }
|
||||
.task-card-priority.low { background: var(--color-priority-low); }
|
||||
|
||||
.task-card-ref {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.task-card-title {
|
||||
flex: 1;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.task-card-due {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-card-due.overdue {
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-card-due.today {
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-card-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-card-preview {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-badge.open { color: var(--color-status-open); background: var(--color-status-open-bg); }
|
||||
.status-badge.assigned { color: var(--color-status-assigned); background: var(--color-status-assigned-bg); }
|
||||
.status-badge.answered { color: var(--color-status-answered); background: var(--color-status-answered-bg); }
|
||||
.status-badge.vetted { color: var(--color-status-vetted); background: var(--color-status-vetted-bg); }
|
||||
.status-badge.published { color: var(--color-status-published); background: var(--color-status-published-bg); }
|
||||
.status-badge.closed { color: var(--color-status-closed); background: var(--color-status-closed-bg); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.input::placeholder, .textarea::placeholder {
|
||||
color: var(--color-input-placeholder);
|
||||
}
|
||||
|
||||
.input:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-input-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Thread messages */
|
||||
.thread-message {
|
||||
padding: var(--space-md);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.thread-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.thread-message-author {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.thread-message-time {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.thread-message-body {
|
||||
color: var(--color-text-primary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.thread-message-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.thread-message-attachment:hover {
|
||||
color: var(--color-text-link-hover);
|
||||
}
|
||||
|
||||
/* Routing chain */
|
||||
.routing-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.routing-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.routing-step.current {
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.routing-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
UTILITY CLASSES
|
||||
========================================================================== */
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-sm { gap: var(--space-sm); }
|
||||
.gap-md { gap: var(--space-md); }
|
||||
.gap-lg { gap: var(--space-lg); }
|
||||
|
||||
.mt-sm { margin-top: var(--space-sm); }
|
||||
.mt-md { margin-top: var(--space-md); }
|
||||
.mt-lg { margin-top: var(--space-lg); }
|
||||
.mb-sm { margin-bottom: var(--space-sm); }
|
||||
.mb-md { margin-bottom: var(--space-md); }
|
||||
.mb-lg { margin-bottom: var(--space-lg); }
|
||||
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-muted { color: var(--color-text-secondary); }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin — Dealspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full font-medium">Super Admin</span>
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex">
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
App Home</a>
|
||||
<a href="/admin" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Dashboard</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-8 max-w-6xl">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">Admin Dashboard</h1>
|
||||
<p class="text-[#94a3b8] text-sm mb-8">Platform overview and management.</p>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
|
||||
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Users</div>
|
||||
<div id="statUsers" class="text-3xl font-bold text-white">—</div>
|
||||
</div>
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
|
||||
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Projects</div>
|
||||
<div id="statProjects" class="text-3xl font-bold text-white">—</div>
|
||||
</div>
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
|
||||
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Organizations</div>
|
||||
<div id="statOrgs" class="text-3xl font-bold text-white">—</div>
|
||||
</div>
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
|
||||
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Active Sessions</div>
|
||||
<div id="statSessions" class="text-3xl font-bold text-white">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white">All Users</h2>
|
||||
</div>
|
||||
<div id="userList" class="space-y-2"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) window.location.href = '/app/login';
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
if (!user.is_super_admin) window.location.href = '/app/tasks';
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
|
||||
}
|
||||
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [usersRes, projectsRes, orgsRes] = await Promise.all([
|
||||
fetchAPI('/api/admin/users'), fetchAPI('/api/projects'), fetchAPI('/api/orgs')
|
||||
]);
|
||||
const users = await usersRes.json();
|
||||
const projects = await projectsRes.json();
|
||||
const orgs = await orgsRes.json();
|
||||
document.getElementById('statUsers').textContent = Array.isArray(users) ? users.length : '?';
|
||||
document.getElementById('statProjects').textContent = Array.isArray(projects) ? projects.length : '?';
|
||||
document.getElementById('statOrgs').textContent = Array.isArray(orgs) ? orgs.length : '?';
|
||||
document.getElementById('statSessions').textContent = '—';
|
||||
|
||||
if (Array.isArray(users) && users.length > 0) {
|
||||
document.getElementById('userList').innerHTML = users.map(u => `
|
||||
<div class="flex items-center gap-4 px-4 py-3 rounded-lg bg-[#0a1628] border border-white/[0.05]">
|
||||
<div class="w-8 h-8 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-sm font-semibold">${(u.name||u.email||'?')[0].toUpperCase()}</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-white text-sm font-medium">${escHtml(u.name || u.email)}</div>
|
||||
${u.name ? `<div class="text-[#475569] text-xs">${escHtml(u.email)}</div>` : ''}
|
||||
</div>
|
||||
${u.is_super_admin ? '<span class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full">super admin</span>' : ''}
|
||||
<span class="text-xs text-[#475569]">${new Date(u.created_at).toLocaleDateString()}</span>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
document.getElementById('userList').innerHTML = '<div class="text-[#94a3b8] text-sm">No users found.</div>';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
loadStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Organizations — Dealspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
.card:hover { border-color: rgba(201,168,76,0.3); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex">
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
My Tasks</a>
|
||||
<a href="/app/projects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
Projects</a>
|
||||
<a href="/app/orgs" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
Organizations</a>
|
||||
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
|
||||
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Admin</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-8 max-w-5xl">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Organizations</h1>
|
||||
<p class="text-[#94a3b8] text-sm">Company directory — parties eligible to participate in deals.</p>
|
||||
</div>
|
||||
<button id="newOrgBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Organization</button>
|
||||
</div>
|
||||
<div id="orgGrid" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="text-[#94a3b8] text-sm col-span-2">Loading...</div>
|
||||
</div>
|
||||
<div id="emptyState" class="hidden text-center py-20">
|
||||
<div class="text-5xl mb-4">🏢</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">No organizations yet</h2>
|
||||
<p class="text-[#94a3b8] text-sm">Add buyer, seller, IB, and advisor organizations.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- New Org Modal -->
|
||||
<div id="newOrgModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">New Organization</h2>
|
||||
<div id="modalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
<div class="space-y-4">
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Organization Name</label>
|
||||
<input id="oName" type="text" placeholder="James LLC" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Allowed Email Domains <span class="text-red-400">*</span></label>
|
||||
<input id="oDomains" type="text" placeholder="jamesllc.com, kaseya.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]">
|
||||
<p class="text-[#475569] text-xs mt-1">Comma-separated. Only emails from these domains can be invited for this org.</p></div>
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Default Role</label>
|
||||
<select id="oRole" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
|
||||
<option value="seller">Seller</option><option value="buyer">Buyer</option><option value="ib">IB Advisor</option><option value="advisor">Advisor</option>
|
||||
</select></div>
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Website</label>
|
||||
<input id="oWebsite" type="url" placeholder="https://jamesllc.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button onclick="closeModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
|
||||
<button id="createBtn" onclick="createOrg()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) window.location.href = '/app/login';
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
if (user.is_super_admin) { document.getElementById('adminLinks').classList.remove('hidden'); document.getElementById('newOrgBtn').classList.remove('hidden'); }
|
||||
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
|
||||
}
|
||||
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
|
||||
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/orgs');
|
||||
const orgs = await res.json();
|
||||
const grid = document.getElementById('orgGrid');
|
||||
if (!orgs || orgs.length === 0) { grid.classList.add('hidden'); document.getElementById('emptyState').classList.remove('hidden'); return; }
|
||||
grid.innerHTML = orgs.map(o => {
|
||||
const d = parseData(o.data_text);
|
||||
const rc = roleColors[d.role] || 'bg-gray-500/20 text-gray-300';
|
||||
const domains = Array.isArray(d.domains) ? d.domains : (d.domains ? [d.domains] : []);
|
||||
return `<div class="card bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 transition">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h3 class="text-white font-semibold text-lg leading-tight">${escHtml(d.name || o.summary || 'Untitled')}</h3>
|
||||
<span class="ml-2 shrink-0 px-2 py-0.5 rounded-full text-xs font-medium capitalize ${rc}">${d.role || '?'}</span>
|
||||
</div>
|
||||
<div class="flex gap-1.5 flex-wrap mb-3">${domains.map(dm => `<span class="text-xs font-mono text-[#94a3b8] bg-white/[0.05] px-2 py-0.5 rounded">@${escHtml(dm)}</span>`).join('')}</div>
|
||||
${d.website ? `<a href="${escHtml(d.website)}" target="_blank" class="text-xs text-[#c9a84c] hover:underline">${escHtml(d.website)}</a>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) { document.getElementById('orgGrid').innerHTML = '<div class="text-red-400 text-sm col-span-2">Failed to load.</div>'; }
|
||||
}
|
||||
|
||||
function openModal() { document.getElementById('newOrgModal').classList.remove('hidden'); document.getElementById('oName').focus(); }
|
||||
function closeModal() { document.getElementById('newOrgModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); }
|
||||
document.getElementById('newOrgBtn').onclick = openModal;
|
||||
|
||||
async function createOrg() {
|
||||
const name = document.getElementById('oName').value.trim();
|
||||
const domainsRaw = document.getElementById('oDomains').value.trim();
|
||||
const role = document.getElementById('oRole').value;
|
||||
const website = document.getElementById('oWebsite').value.trim();
|
||||
const errEl = document.getElementById('modalError');
|
||||
const btn = document.getElementById('createBtn');
|
||||
if (!name) { errEl.textContent = 'Organization name is required'; errEl.classList.remove('hidden'); return; }
|
||||
if (!domainsRaw) { errEl.textContent = 'At least one email domain is required'; errEl.classList.remove('hidden'); return; }
|
||||
const domains = domainsRaw.split(',').map(d => d.trim().replace(/^@/, '')).filter(Boolean);
|
||||
if (!domains.length) { errEl.textContent = 'Invalid domain format'; errEl.classList.remove('hidden'); return; }
|
||||
btn.disabled = true; btn.textContent = 'Creating...'; errEl.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetchAPI('/api/orgs', { method: 'POST', body: JSON.stringify({ name, domains, role, website }) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create organization');
|
||||
closeModal();
|
||||
document.getElementById('oName').value = '';
|
||||
document.getElementById('oDomains').value = '';
|
||||
document.getElementById('oWebsite').value = '';
|
||||
loadOrgs();
|
||||
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; }
|
||||
}
|
||||
|
||||
loadOrgs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project — Dealspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
|
||||
<span class="text-white/20">/</span>
|
||||
<a href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Projects</a>
|
||||
<span class="text-white/20">/</span>
|
||||
<span id="projectName" class="text-sm text-white font-medium">Loading...</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex">
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
My Tasks</a>
|
||||
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
Projects</a>
|
||||
<a href="/app/orgs" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
Organizations</a>
|
||||
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
|
||||
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Admin</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-8 max-w-6xl">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h1 id="projectTitle" class="text-2xl font-bold text-white">Loading...</h1>
|
||||
<span id="projectStatus" class="px-2.5 py-0.5 rounded-full text-xs font-medium"></span>
|
||||
</div>
|
||||
<p id="projectDesc" class="text-[#94a3b8] text-sm"></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="importBtn" onclick="openImportModal()" class="px-4 py-2 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
|
||||
Import
|
||||
</button>
|
||||
<button id="newRequestBtn" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Request</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6 border-b border-white/[0.08] mb-6">
|
||||
<button class="tab active pb-3 text-sm font-medium transition" onclick="switchTab('requests', this)">Requests</button>
|
||||
<button class="tab pb-3 text-sm font-medium text-[#94a3b8] transition" onclick="switchTab('orgs', this)">Organizations</button>
|
||||
<button class="tab pb-3 text-sm font-medium text-[#94a3b8] transition" onclick="switchTab('team', this)">Team</button>
|
||||
</div>
|
||||
<div id="tab-requests">
|
||||
<div id="requestList" class="space-y-4"><div class="text-[#94a3b8] text-sm">Loading requests...</div></div>
|
||||
<div id="requestEmpty" class="hidden text-center py-16">
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">No requests yet</h3>
|
||||
<p class="text-[#94a3b8] text-sm mb-4">Import a diligence checklist or create requests manually.</p>
|
||||
<button onclick="openImportModal()" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import Checklist</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-orgs" class="hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="text-[#94a3b8] text-sm">Organizations participating in this deal.</p>
|
||||
<button id="addOrgBtn" class="px-3 py-1.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition">+ Add Org</button>
|
||||
</div>
|
||||
<div id="orgList" class="space-y-3"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
|
||||
</div>
|
||||
<div id="tab-team" class="hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="text-[#94a3b8] text-sm">People with access to this deal.</p>
|
||||
<button id="inviteBtn" class="px-3 py-1.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ Invite</button>
|
||||
</div>
|
||||
<div id="teamList" class="space-y-2"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="importModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div class="bg-[#0d1f3c] rounded-2xl p-6 w-full max-w-md border border-white/[0.08]">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Import Diligence Checklist</h2>
|
||||
<form id="importForm" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[#94a3b8] mb-1.5">File (CSV or XLSX)</label>
|
||||
<input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls" required class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[#94a3b8] mb-1.5">Import Mode</label>
|
||||
<select id="importMode" name="mode" class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
|
||||
<option value="add">Add to existing requests</option>
|
||||
<option value="replace">Replace all requests</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-[#94a3b8] mb-1.5">Section Filter (optional)</label>
|
||||
<input type="text" id="sectionFilter" name="section_filter" placeholder="e.g. Financial" class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder:text-[#64748b]">
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="createWorkstreams" name="create_workstreams" class="rounded border-white/[0.2]">
|
||||
<span class="text-sm text-[#94a3b8]">Create workstreams from sections</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick="closeImportModal()" class="flex-1 px-4 py-2 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition">Cancel</button>
|
||||
<button type="submit" id="importSubmitBtn" class="flex-1 px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) window.location.href = '/app/login';
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
const projectID = location.pathname.split('/').pop();
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token };
|
||||
if (!(opts.body instanceof FormData)) opts.headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
|
||||
}
|
||||
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
|
||||
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300' };
|
||||
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
|
||||
const expandedSections = new Set();
|
||||
async function loadProject() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/detail');
|
||||
if (!res.ok) { document.getElementById('projectTitle').textContent = 'Not found'; return; }
|
||||
const p = await res.json();
|
||||
const proj = p.project;
|
||||
const d = parseData(proj.data_text);
|
||||
const name = d.name || proj.summary || 'Untitled';
|
||||
document.title = name + ' — Dealspace';
|
||||
document.getElementById('projectName').textContent = name;
|
||||
document.getElementById('projectTitle').textContent = name;
|
||||
document.getElementById('projectDesc').textContent = d.description || '';
|
||||
const status = d.status || 'active';
|
||||
const sc = statusColors[status] || 'bg-gray-500/20 text-gray-300';
|
||||
document.getElementById('projectStatus').className = 'px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ' + sc;
|
||||
document.getElementById('projectStatus').textContent = status;
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
async function loadRequests() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/requests');
|
||||
const items = await res.json();
|
||||
const list = document.getElementById('requestList');
|
||||
if (!items || items.length === 0) { list.classList.add('hidden'); document.getElementById('requestEmpty').classList.remove('hidden'); return; }
|
||||
list.classList.remove('hidden'); document.getElementById('requestEmpty').classList.add('hidden');
|
||||
const sections = {};
|
||||
for (const r of items) { const sec = r.section || 'Unsorted'; if (!sections[sec]) sections[sec] = []; sections[sec].push(r); }
|
||||
const sortedSections = Object.keys(sections).sort();
|
||||
list.innerHTML = sortedSections.map(sec => {
|
||||
const reqs = sections[sec]; const isExpanded = expandedSections.has(sec);
|
||||
const statusCounts = { open: 0, in_progress: 0, answered: 0, not_applicable: 0 };
|
||||
reqs.forEach(r => { statusCounts[r.status || 'open']++; });
|
||||
return '<div class="border border-white/[0.08] rounded-xl overflow-hidden"><div class="section-header flex items-center justify-between px-5 py-3 bg-[#0d1f3c]" onclick="toggleSection(\'' + escHtml(sec) + '\')"><div class="flex items-center gap-3"><svg class="w-4 h-4 text-[#94a3b8] transition ' + (isExpanded ? 'rotate-90' : '') + '" id="chevron-' + escHtml(sec) + '" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg><span class="text-white font-medium">' + escHtml(sec) + '</span><span class="text-[#94a3b8] text-xs">(' + reqs.length + ' items)</span></div><div class="flex gap-2">' + (statusCounts.open > 0 ? '<span class="px-2 py-0.5 rounded text-xs status-open">' + statusCounts.open + ' open</span>' : '') + (statusCounts.answered > 0 ? '<span class="px-2 py-0.5 rounded text-xs status-answered">' + statusCounts.answered + ' answered</span>' : '') + '</div></div><div id="section-' + escHtml(sec) + '" class="' + (isExpanded ? '' : 'hidden') + '">' + reqs.map(r => '<a href="/app/requests/' + r.entry_id + '" class="req-row flex items-center gap-4 px-5 py-3 border-t border-white/[0.05] transition cursor-pointer"><div class="w-16 shrink-0"><span class="text-xs font-mono text-[#94a3b8]">' + escHtml(r.item_number || '—') + '</span></div><div class="flex-1 min-w-0"><span class="text-white text-sm truncate block">' + escHtml(r.title || 'Untitled') + '</span></div><span class="shrink-0 w-2 h-2 rounded-full ' + (r.priority === 'high' ? 'bg-red-400' : r.priority === 'low' ? 'bg-green-400' : 'bg-yellow-400') + '" title="' + r.priority + ' priority"></span><span class="shrink-0 px-2.5 py-0.5 rounded text-xs font-medium status-' + (r.status || 'open') + '">' + (r.status || 'open').replace('_', ' ') + '</span></a>').join('') + '</div></div>';
|
||||
}).join('');
|
||||
} catch(e) { console.error(e); document.getElementById('requestList').innerHTML = '<div class="text-red-400 text-sm">Failed to load requests.</div>'; }
|
||||
}
|
||||
function toggleSection(sec) {
|
||||
const el = document.getElementById('section-' + sec); const chevron = document.getElementById('chevron-' + sec);
|
||||
if (el.classList.contains('hidden')) { el.classList.remove('hidden'); chevron.classList.add('rotate-90'); expandedSections.add(sec); }
|
||||
else { el.classList.add('hidden'); chevron.classList.remove('rotate-90'); expandedSections.delete(sec); }
|
||||
}
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/orgs');
|
||||
const orgs = await res.json();
|
||||
const list = document.getElementById('orgList');
|
||||
if (!orgs || orgs.length === 0) { list.innerHTML = '<div class="text-[#94a3b8] text-sm">No organizations added yet.</div>'; return; }
|
||||
list.innerHTML = orgs.map(o => { const rc = roleColors[o.role] || 'bg-gray-500/20 text-gray-300'; const domains = o.org_domains || [];
|
||||
return '<div class="flex items-center gap-4 px-5 py-4 rounded-xl bg-[#0d1f3c] border border-white/[0.08]"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="text-white font-medium">' + escHtml(o.org_name || 'Unknown') + '</span><span class="px-2 py-0.5 rounded-full text-xs font-medium capitalize ' + rc + '">' + (o.role || '?') + '</span>' + (o.domain_lock ? '<span class="px-2 py-0.5 rounded-full text-xs bg-white/[0.05] text-[#94a3b8]">🔒 domain locked</span>' : '') + '</div>' + (domains.length > 0 ? '<div class="flex gap-1.5 flex-wrap">' + domains.map(dm => '<span class="text-xs text-[#94a3b8] font-mono bg-white/[0.05] px-2 py-0.5 rounded">@' + dm + '</span>').join('') + '</div>' : '') + '</div></div>';
|
||||
}).join('');
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
async function loadTeam() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/members');
|
||||
const members = await res.json();
|
||||
const list = document.getElementById('teamList');
|
||||
if (!members || members.length === 0) { list.innerHTML = '<div class="text-[#94a3b8] text-sm">No team members yet.</div>'; return; }
|
||||
list.innerHTML = members.map(m => '<div class="flex items-center gap-4 px-5 py-3 rounded-xl bg-[#0d1f3c] border border-white/[0.08]"><div class="w-8 h-8 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] font-semibold text-sm">' + (m.name||m.email||'?')[0].toUpperCase() + '</div><div class="flex-1"><div class="text-white text-sm font-medium">' + escHtml(m.name || m.email) + '</div>' + (m.name ? '<div class="text-[#94a3b8] text-xs">' + escHtml(m.email) + '</div>' : '') + '</div><span class="text-xs text-[#94a3b8] capitalize">' + (m.role || 'member') + '</span></div>').join('');
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
function switchTab(name, el) {
|
||||
document.querySelectorAll('.tab').forEach(t => { t.classList.remove('active','text-white'); t.classList.add('text-[#94a3b8]'); });
|
||||
el.classList.add('active','text-white'); el.classList.remove('text-[#94a3b8]');
|
||||
document.getElementById('tab-requests').classList.toggle('hidden', name !== 'requests');
|
||||
document.getElementById('tab-orgs').classList.toggle('hidden', name !== 'orgs');
|
||||
document.getElementById('tab-team').classList.toggle('hidden', name !== 'team');
|
||||
if (name === 'orgs') loadOrgs(); if (name === 'team') loadTeam();
|
||||
}
|
||||
function openImportModal() { document.getElementById('importModal').classList.remove('hidden'); }
|
||||
function closeImportModal() { document.getElementById('importModal').classList.add('hidden'); document.getElementById('importForm').reset(); }
|
||||
document.getElementById('importForm').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('importSubmitBtn'); btn.disabled = true; btn.textContent = 'Importing...';
|
||||
const formData = new FormData();
|
||||
formData.append('file', document.getElementById('importFile').files[0]);
|
||||
formData.append('mode', document.getElementById('importMode').value);
|
||||
formData.append('section_filter', document.getElementById('sectionFilter').value);
|
||||
formData.append('create_workstreams', document.getElementById('createWorkstreams').checked ? 'true' : 'false');
|
||||
try {
|
||||
const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
|
||||
const data = await res.json();
|
||||
if (res.ok) { closeImportModal(); loadRequests(); alert('Imported ' + data.imported + ' requests from ' + (data.sections?.length || 0) + ' sections. ' + (data.skipped || 0) + ' skipped.'); }
|
||||
else { alert('Import failed: ' + (data.error || 'Unknown error')); }
|
||||
} catch (err) { alert('Import failed: ' + err.message); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Import'; }
|
||||
};
|
||||
document.getElementById('newRequestBtn').onclick = () => {
|
||||
const title = prompt('Request title:'); if (!title) return;
|
||||
fetchAPI('/api/projects/' + projectID + '/entries', { method: 'POST', body: JSON.stringify({ project_id: projectID, parent_id: projectID, type: 'request', depth: 1, summary: title, data: JSON.stringify({ title: title, status: 'open', priority: 'medium' }), stage: 'pre_dataroom' })
|
||||
}).then(r => r.json()).then(d => { if (d.entry_id) window.location.href = '/app/requests/' + d.entry_id; else loadRequests(); });
|
||||
};
|
||||
document.getElementById('importModal').onclick = (e) => { if (e.target.id === 'importModal') closeImportModal(); };
|
||||
loadProject(); loadRequests();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Projects — Dealspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
.card:hover { border-color: rgba(201,168,76,0.3); transform: translateY(-1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex">
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
My Tasks</a>
|
||||
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
Projects</a>
|
||||
<a href="/app/orgs" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
|
||||
Organizations</a>
|
||||
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
|
||||
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Admin</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-8 max-w-6xl">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Projects</h1>
|
||||
<p class="text-[#94a3b8] text-sm">All deals you have access to.</p>
|
||||
</div>
|
||||
<button id="newProjectBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Project</button>
|
||||
</div>
|
||||
<div id="projectGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div class="text-[#94a3b8] text-sm col-span-3">Loading projects...</div>
|
||||
</div>
|
||||
<div id="emptyState" class="hidden text-center py-20">
|
||||
<div class="text-5xl mb-4">📁</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">No projects yet</h2>
|
||||
<p class="text-[#94a3b8]">You haven't been added to any deals yet.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<div id="newProjectModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">New Project</h2>
|
||||
<div id="modalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
<div class="space-y-4">
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Deal Name</label>
|
||||
<input id="pName" type="text" placeholder="Project James" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Description</label>
|
||||
<textarea id="pDesc" rows="2" placeholder="Brief description of this deal..." class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] resize-none"></textarea></div>
|
||||
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Status</label>
|
||||
<select id="pStatus" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
|
||||
<option value="active">Active</option><option value="draft">Draft</option><option value="closed">Closed</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button onclick="closeModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
|
||||
<button id="createBtn" onclick="createProject()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) window.location.href = '/app/login';
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
if (user.is_super_admin) {
|
||||
document.getElementById('adminLinks').classList.remove('hidden');
|
||||
document.getElementById('newProjectBtn').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
|
||||
}
|
||||
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
|
||||
|
||||
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
|
||||
const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300', completed: 'bg-blue-500/20 text-blue-300' };
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects');
|
||||
const projects = await res.json();
|
||||
const grid = document.getElementById('projectGrid');
|
||||
if (!projects || projects.length === 0) {
|
||||
grid.classList.add('hidden');
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = projects.map(p => {
|
||||
const d = parseData(p.data_text);
|
||||
const status = d.status || 'active';
|
||||
const sc = statusColors[status] || statusColors.active;
|
||||
return `<a href="/app/projects/${p.entry_id}" class="card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 transition cursor-pointer">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h3 class="text-white font-semibold text-lg leading-tight">${escHtml(d.name || p.summary || 'Untitled')}</h3>
|
||||
<span class="ml-2 shrink-0 px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
|
||||
</div>
|
||||
${d.description ? `<p class="text-[#94a3b8] text-sm mb-4 line-clamp-2">${escHtml(d.description)}</p>` : '<div class="mb-4"></div>'}
|
||||
<div class="flex items-center gap-2 text-xs text-[#475569]">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
${new Date(p.created_at).toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
} catch(e) { document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-3">Failed to load projects.</div>'; }
|
||||
}
|
||||
|
||||
function openModal() { document.getElementById('newProjectModal').classList.remove('hidden'); document.getElementById('pName').focus(); }
|
||||
function closeModal() { document.getElementById('newProjectModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); }
|
||||
document.getElementById('newProjectBtn').onclick = openModal;
|
||||
|
||||
async function createProject() {
|
||||
const name = document.getElementById('pName').value.trim();
|
||||
const desc = document.getElementById('pDesc').value.trim();
|
||||
const status = document.getElementById('pStatus').value;
|
||||
const errEl = document.getElementById('modalError');
|
||||
const btn = document.getElementById('createBtn');
|
||||
if (!name) { errEl.textContent = 'Deal name is required'; errEl.classList.remove('hidden'); return; }
|
||||
btn.disabled = true; btn.textContent = 'Creating...';
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects', { method: 'POST', body: JSON.stringify({ name, description: desc, status }) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create project');
|
||||
window.location.href = '/app/projects/' + data.entry_id;
|
||||
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; }
|
||||
}
|
||||
|
||||
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
loadProjects();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request — Dealspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
|
||||
<span class="text-white/20">/</span>
|
||||
<a href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Projects</a>
|
||||
<span class="text-white/20">/</span>
|
||||
<a id="backToProject" href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Project</a>
|
||||
<span class="text-white/20">/</span>
|
||||
<span id="reqRef" class="text-sm text-white font-medium">Request</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex">
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
My Tasks</a>
|
||||
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
Projects</a>
|
||||
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
|
||||
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Admin</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-1 p-8 max-w-4xl">
|
||||
<!-- Request Header -->
|
||||
<div id="reqHeader" class="mb-8">
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<div class="flex-1">
|
||||
<h1 id="reqTitle" class="text-2xl font-bold text-white mb-2">Loading...</h1>
|
||||
<p id="reqDesc" class="text-[#94a3b8] text-sm"></p>
|
||||
</div>
|
||||
<span id="reqStatus" class="shrink-0 px-3 py-1 rounded-full text-sm font-medium"></span>
|
||||
</div>
|
||||
<div class="flex gap-3 flex-wrap text-xs text-[#475569]">
|
||||
<span id="reqDue"></span>
|
||||
<span id="reqAssignee"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Answer / Upload -->
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-4">Response</h2>
|
||||
<div id="answers" class="space-y-4 mb-6"></div>
|
||||
<div id="uploadArea" class="border-2 border-dashed border-white/[0.08] rounded-xl p-8 text-center hover:border-[#c9a84c]/40 transition cursor-pointer" onclick="document.getElementById('fileInput').click()">
|
||||
<div class="text-3xl mb-2">📎</div>
|
||||
<p class="text-[#94a3b8] text-sm">Drop files here or click to upload</p>
|
||||
<p class="text-[#475569] text-xs mt-1">PDF, DOCX, XLSX, images</p>
|
||||
<input id="fileInput" type="file" multiple class="hidden" onchange="uploadFiles(this.files)">
|
||||
</div>
|
||||
<div id="uploadStatus" class="mt-3 text-sm text-[#94a3b8]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Activity / Comments -->
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
|
||||
<h2 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-4">Comments</h2>
|
||||
<div id="comments" class="space-y-3 mb-4"></div>
|
||||
<div class="flex gap-3">
|
||||
<textarea id="commentText" rows="2" placeholder="Add a comment..." class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] text-sm resize-none"></textarea>
|
||||
<button onclick="postComment()" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition self-end">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) window.location.href = '/app/login';
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
const reqID = location.pathname.split('/').pop();
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
|
||||
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token };
|
||||
if (opts.body && typeof opts.body === 'string') opts.headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
|
||||
}
|
||||
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
|
||||
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
|
||||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
const statusColors = { open: 'bg-yellow-500/20 text-yellow-300', answered: 'bg-green-500/20 text-green-300', closed: 'bg-gray-500/20 text-gray-300', 'under-review': 'bg-blue-500/20 text-blue-300' };
|
||||
|
||||
async function loadRequest() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/entries/' + reqID);
|
||||
if (!res.ok) { document.getElementById('reqTitle').textContent = 'Not found'; return; }
|
||||
const req = await res.json();
|
||||
const d = parseData(req.data_text);
|
||||
const title = d.title || req.summary || 'Untitled';
|
||||
document.title = title + ' — Dealspace';
|
||||
document.getElementById('reqRef').textContent = d.ref || title;
|
||||
document.getElementById('reqTitle').textContent = title;
|
||||
document.getElementById('reqDesc').textContent = d.description || '';
|
||||
if (req.project_id) document.getElementById('backToProject').href = '/app/projects/' + req.project_id;
|
||||
const status = d.status || 'open';
|
||||
const sc = statusColors[status] || 'bg-gray-500/20 text-gray-300';
|
||||
document.getElementById('reqStatus').className = 'shrink-0 px-3 py-1 rounded-full text-sm font-medium capitalize ' + sc;
|
||||
document.getElementById('reqStatus').textContent = status;
|
||||
if (d.due_date) document.getElementById('reqDue').textContent = '📅 Due: ' + d.due_date;
|
||||
if (d.assignee) document.getElementById('reqAssignee').textContent = '👤 ' + d.assignee;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadAnswers() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/entries/' + reqID + '/children?type=answer');
|
||||
const items = await res.json();
|
||||
const el = document.getElementById('answers');
|
||||
if (!items || items.length === 0) { el.innerHTML = '<p class="text-[#475569] text-sm">No documents uploaded yet.</p>'; return; }
|
||||
el.innerHTML = items.map(a => {
|
||||
const d = parseData(a.data_text);
|
||||
const name = d.filename || d.name || a.summary || 'Document';
|
||||
return `<div class="flex items-center gap-3 px-4 py-3 rounded-lg bg-[#0a1628] border border-white/[0.05]">
|
||||
<span class="text-2xl">${name.endsWith('.pdf') ? '📄' : name.match(/\.(jpg|jpeg|png|gif)$/i) ? '🖼️' : '📎'}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-white text-sm font-medium truncate">${escHtml(name)}</div>
|
||||
<div class="text-[#475569] text-xs">${new Date(a.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<a href="/api/entries/${a.entry_id}/download" class="text-[#c9a84c] text-sm hover:underline">Download</a>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function uploadFiles(files) {
|
||||
const status = document.getElementById('uploadStatus');
|
||||
for (const file of files) {
|
||||
status.textContent = 'Uploading ' + file.name + '...';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('parent_id', reqID);
|
||||
try {
|
||||
const res = await fetchAPI('/api/upload', { method: 'POST', body: fd });
|
||||
if (res.ok) { status.textContent = file.name + ' uploaded.'; loadAnswers(); }
|
||||
else { status.textContent = 'Upload failed for ' + file.name; }
|
||||
} catch(e) { status.textContent = 'Error: ' + e.message; }
|
||||
}
|
||||
setTimeout(() => status.textContent = '', 3000);
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/entries/' + reqID + '/children?type=comment');
|
||||
const items = await res.json();
|
||||
const el = document.getElementById('comments');
|
||||
if (!items || items.length === 0) { el.innerHTML = '<p class="text-[#475569] text-sm">No comments yet.</p>'; return; }
|
||||
el.innerHTML = items.map(c => {
|
||||
const d = parseData(c.data_text);
|
||||
return `<div class="flex gap-3">
|
||||
<div class="w-7 h-7 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-xs font-semibold shrink-0 mt-0.5">${(d.author||'?')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-white text-sm font-medium">${escHtml(d.author||'Unknown')}</span>
|
||||
<span class="text-[#475569] text-xs">${new Date(c.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p class="text-[#94a3b8] text-sm">${escHtml(d.text||'')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function postComment() {
|
||||
const text = document.getElementById('commentText').value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
const res = await fetchAPI('/api/entries/' + reqID + '/children', { method: 'POST', body: JSON.stringify({ type: 'comment', data: { text, author: user.name || user.email } }) });
|
||||
if (res.ok) { document.getElementById('commentText').value = ''; loadComments(); }
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
const ua = document.getElementById('uploadArea');
|
||||
ua.addEventListener('dragover', e => { e.preventDefault(); ua.classList.add('border-[#c9a84c]/60'); });
|
||||
ua.addEventListener('dragleave', () => ua.classList.remove('border-[#c9a84c]/60'));
|
||||
ua.addEventListener('drop', e => { e.preventDefault(); ua.classList.remove('border-[#c9a84c]/60'); uploadFiles(e.dataTransfer.files); });
|
||||
|
||||
loadRequest();
|
||||
loadAnswers();
|
||||
loadComments();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Tasks — Dealspace</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
* { font-family: 'Inter', sans-serif; }
|
||||
body { background: #0a1628; margin: 0; }
|
||||
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
|
||||
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
|
||||
.task-card:hover { border-color: rgba(201,168,76,0.3); transform: translateY(-1px); }
|
||||
.priority-high { background: #ef4444; }
|
||||
.priority-normal { background: #c9a84c; }
|
||||
.priority-low { background: #22c55e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Nav -->
|
||||
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight">
|
||||
<span class="text-[#c9a84c]">Deal</span>space
|
||||
</a>
|
||||
<select id="projectSwitcher" class="bg-[#0a1628] border border-white/[0.08] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#c9a84c]">
|
||||
<option value="">All Projects</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="userName" class="text-sm text-[#94a3b8]"></span>
|
||||
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex">
|
||||
<!-- Sidebar -->
|
||||
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
|
||||
<div class="p-3 space-y-0.5">
|
||||
<a href="/app/tasks" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
|
||||
<svg class="w-4 h-4 text-[#94a3b8]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
|
||||
My Tasks
|
||||
</a>
|
||||
<a href="/app/projects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
Projects
|
||||
</a>
|
||||
<div id="adminLinks" class="hidden">
|
||||
<div class="border-t border-white/[0.08] my-3"></div>
|
||||
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 p-8 max-w-5xl">
|
||||
<!-- Greeting -->
|
||||
<div class="mb-8">
|
||||
<h1 id="greeting" class="text-2xl font-bold text-white mb-1"></h1>
|
||||
<p class="text-[#94a3b8] text-sm">Here are your pending tasks.</p>
|
||||
</div>
|
||||
|
||||
<!-- Task List -->
|
||||
<div id="taskList" class="space-y-3">
|
||||
<div class="text-[#94a3b8] text-sm">Loading tasks...</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden text-center py-20">
|
||||
<div class="text-5xl mb-4">🎉</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">You're all caught up</h2>
|
||||
<p class="text-[#94a3b8]">No tasks need your attention right now.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auth check
|
||||
const token = localStorage.getItem('ds_token');
|
||||
if (!token) { window.location.href = '/app/login'; }
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
|
||||
|
||||
function fetchAPI(path, opts = {}) {
|
||||
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
return fetch(path, opts).then(r => {
|
||||
if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; }
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => {
|
||||
localStorage.removeItem('ds_token');
|
||||
localStorage.removeItem('ds_user');
|
||||
window.location.href = '/app/login';
|
||||
});
|
||||
}
|
||||
|
||||
// Greeting
|
||||
const hour = new Date().getHours();
|
||||
const greetWord = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
document.getElementById('greeting').textContent = greetWord + ', ' + (user.name || 'there');
|
||||
document.getElementById('userName').textContent = user.name || user.email || '';
|
||||
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
|
||||
|
||||
// Load tasks
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/tasks');
|
||||
const tasks = await res.json();
|
||||
const list = document.getElementById('taskList');
|
||||
const empty = document.getElementById('emptyState');
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
list.classList.add('hidden');
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = tasks.map(t => {
|
||||
const data = parseData(t.data_text);
|
||||
const priority = data.priority || 'normal';
|
||||
const title = data.title || t.summary || 'Untitled';
|
||||
const ref = data.ref || '';
|
||||
const due = data.due_date || '';
|
||||
const status = data.status || 'open';
|
||||
const projectName = t.project_id ? t.project_id.substring(0, 8) : '';
|
||||
|
||||
return `
|
||||
<a href="/app/requests/${t.entry_id}" class="task-card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 transition cursor-pointer">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full priority-${priority} shrink-0"></span>
|
||||
${ref ? `<span class="text-xs font-mono text-[#94a3b8]">${ref}</span>` : ''}
|
||||
<span class="text-white font-medium flex-1">${escapeHtml(title)}</span>
|
||||
${due ? `<span class="text-xs text-[#94a3b8]">Due: ${due}</span>` : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[#475569]">
|
||||
<span class="capitalize px-2 py-0.5 rounded-full bg-white/[0.05] text-[#94a3b8]">${status}</span>
|
||||
<span>${t.type || 'request'}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
document.getElementById('taskList').innerHTML = '<div class="text-red-400 text-sm">Failed to load tasks.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function parseData(text) {
|
||||
if (!text) return {};
|
||||
try { return JSON.parse(text); } catch { return {}; }
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — Dealspace</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
* { font-family: 'Inter', sans-serif; }
|
||||
body { background: #0a1628; }
|
||||
.code-input { letter-spacing: 0.5em; text-align: center; font-size: 1.5rem; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md px-6">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
<span class="text-[#c9a84c]">Deal</span>space
|
||||
</h1>
|
||||
<p class="text-[#94a3b8] mt-2 text-sm">Secure M&A deal management</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Email -->
|
||||
<div id="step-email" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Sign in</h2>
|
||||
<p class="text-[#94a3b8] text-sm mb-6">Enter your email to receive a login code.</p>
|
||||
|
||||
<div id="error-email" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
|
||||
<form id="emailForm" class="space-y-5">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" autofocus
|
||||
placeholder="you@company.com"
|
||||
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||
</div>
|
||||
<button type="submit" id="emailBtn"
|
||||
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||
Send login code
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: OTP Code -->
|
||||
<div id="step-code" class="hidden bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Enter your code</h2>
|
||||
<p class="text-[#94a3b8] text-sm mb-6">
|
||||
We sent a 6-digit code to <span id="sent-email" class="text-white font-medium"></span>
|
||||
</p>
|
||||
|
||||
<div id="error-code" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
|
||||
<form id="codeForm" class="space-y-5">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Login code</label>
|
||||
<input type="text" id="code" name="code" required autocomplete="one-time-code"
|
||||
maxlength="6" inputmode="numeric" pattern="[0-9]*"
|
||||
placeholder="000000"
|
||||
class="code-input w-full px-4 py-3 bg-[#0a1628] border border-white/[0.08] rounded-lg text-[#c9a84c] placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||
</div>
|
||||
<button type="submit" id="codeBtn"
|
||||
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||
Verify & sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<button id="backBtn" class="text-[#94a3b8] text-sm hover:text-white transition">
|
||||
← Use a different email
|
||||
</button>
|
||||
<button id="resendBtn" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition">
|
||||
Resend code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-[#475569] text-xs mt-6">
|
||||
Don’t have an account? Dealspace is invite-only.<br>
|
||||
<a href="/#demo" class="text-[#c9a84c] hover:underline">Request access on muskepo.com</a>
|
||||
</p>
|
||||
<p class="text-center text-[#475569] text-xs mt-3">© 2026 Muskepo B.V. — Amsterdam</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// If already logged in, redirect
|
||||
if (localStorage.getItem('ds_token')) {
|
||||
window.location.href = '/app/tasks';
|
||||
}
|
||||
|
||||
let currentEmail = '';
|
||||
|
||||
// Step 1: Send challenge
|
||||
document.getElementById('emailForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('emailBtn');
|
||||
const errorEl = document.getElementById('error-email');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending code...';
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
currentEmail = document.getElementById('email').value.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to send code');
|
||||
|
||||
// Show code step
|
||||
document.getElementById('sent-email').textContent = currentEmail;
|
||||
document.getElementById('step-email').classList.add('hidden');
|
||||
document.getElementById('step-code').classList.remove('hidden');
|
||||
document.getElementById('code').focus();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send login code';
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Verify code
|
||||
document.getElementById('codeForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('codeBtn');
|
||||
const errorEl = document.getElementById('error-code');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Verifying...';
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: currentEmail,
|
||||
code: document.getElementById('code').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Invalid or expired code');
|
||||
|
||||
localStorage.setItem('ds_token', data.token);
|
||||
localStorage.setItem('ds_user', JSON.stringify(data.user));
|
||||
|
||||
// Everyone lands on /app/tasks — admin panel accessible from nav
|
||||
window.location.href = '/app/tasks';
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Verify & sign in';
|
||||
}
|
||||
});
|
||||
|
||||
// Back button
|
||||
document.getElementById('backBtn').addEventListener('click', () => {
|
||||
document.getElementById('step-code').classList.add('hidden');
|
||||
document.getElementById('step-email').classList.remove('hidden');
|
||||
document.getElementById('code').value = '';
|
||||
document.getElementById('error-code').classList.add('hidden');
|
||||
document.getElementById('email').focus();
|
||||
});
|
||||
|
||||
// Resend button
|
||||
document.getElementById('resendBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('resendBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending...';
|
||||
|
||||
try {
|
||||
await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
});
|
||||
btn.textContent = 'Code sent!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Resend code';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
} catch {
|
||||
btn.textContent = 'Resend code';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits entered
|
||||
document.getElementById('code').addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
if (e.target.value.length === 6) {
|
||||
document.getElementById('codeForm').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Setup — Dealspace</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
* { font-family: 'Inter', sans-serif; }
|
||||
body { background: #0a1628; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md px-6">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
<span class="text-[#c9a84c]">Deal</span>space
|
||||
</h1>
|
||||
<p class="text-[#94a3b8] mt-2 text-sm">First-time setup</p>
|
||||
</div>
|
||||
|
||||
<!-- Setup Card -->
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Create admin account</h2>
|
||||
<p class="text-[#94a3b8] text-sm mb-6">This will be the first administrator account for your Dealspace instance.</p>
|
||||
|
||||
<div id="error" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
<div id="success" class="hidden mb-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-green-400 text-sm"></div>
|
||||
|
||||
<form id="setupForm" class="space-y-5">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Full name</label>
|
||||
<input type="text" id="name" name="name" required autofocus
|
||||
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email"
|
||||
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Password</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password"
|
||||
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
|
||||
<p class="text-xs text-[#475569] mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
<button type="submit" id="submitBtn"
|
||||
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-[#475569] text-xs mt-8">© 2026 Muskepo B.V. — Amsterdam</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const errorEl = document.getElementById('error');
|
||||
const successEl = document.getElementById('success');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
errorEl.classList.add('hidden');
|
||||
successEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('name').value,
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Setup failed');
|
||||
}
|
||||
|
||||
successEl.textContent = 'Admin account created! Redirecting to login...';
|
||||
successEl.classList.remove('hidden');
|
||||
setTimeout(() => { window.location.href = '/app/login'; }, 1500);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create account';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="theme-light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FIN-042: Audited financials FY2024 — Dealspace</title>
|
||||
<link rel="stylesheet" href="../static/themes.css">
|
||||
<style>
|
||||
/* Page-specific layout */
|
||||
.page-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Back link */
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-text-link-hover);
|
||||
}
|
||||
|
||||
/* Request header */
|
||||
.request-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-lg);
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.request-header-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.request-ref-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.request-ref {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.request-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.request-priority {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.priority-dot.high { background: var(--color-priority-high); }
|
||||
.priority-dot.normal { background: var(--color-priority-normal); }
|
||||
.priority-dot.low { background: var(--color-priority-low); }
|
||||
|
||||
.priority-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Routing section */
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.routing-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.routing-box {
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.routing-chain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.routing-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.routing-step.current {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.routing-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.routing-return {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.routing-return strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Actions bar */
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Thread section */
|
||||
.thread-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* Response section */
|
||||
.response-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.response-box {
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.response-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.response-tools {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.response-submit {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Global nav area */
|
||||
.global-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.inbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-header-text);
|
||||
}
|
||||
|
||||
/* Theme switcher (demo only) */
|
||||
.theme-switcher {
|
||||
position: fixed;
|
||||
bottom: var(--space-lg);
|
||||
right: var(--space-lg);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.theme-switcher button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-switcher button:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Two-column layout on larger screens */
|
||||
@media (min-width: 1024px) {
|
||||
.request-content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
.request-main {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.request-sidebar {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar info card */
|
||||
.info-card {
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.info-card-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1px solid var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.info-value.overdue {
|
||||
color: var(--color-error);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-layout">
|
||||
<!-- Global Header -->
|
||||
<header class="global-header">
|
||||
<div class="logo">Dealspace</div>
|
||||
|
||||
<select class="project-select" aria-label="Select project">
|
||||
<option selected>Project Alpha — TargetCo Acquisition</option>
|
||||
<option>Project Beta — MergeCo Deal</option>
|
||||
</select>
|
||||
|
||||
<nav class="global-nav">
|
||||
<a href="/inbox" class="inbox-link">
|
||||
Inbox
|
||||
<span class="badge">3</span>
|
||||
</a>
|
||||
<div class="user-menu">
|
||||
<div class="avatar" role="img" aria-label="User avatar"></div>
|
||||
<span class="user-name">S. Johnson</span>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content container">
|
||||
<!-- Back navigation -->
|
||||
<a href="/inbox" class="back-link">
|
||||
← Back to Inbox
|
||||
</a>
|
||||
|
||||
<!-- Request header -->
|
||||
<header class="request-header">
|
||||
<div class="request-header-main">
|
||||
<div class="request-ref-row">
|
||||
<span class="request-ref">FIN-042</span>
|
||||
<span class="status-badge assigned">Assigned</span>
|
||||
</div>
|
||||
<h1 class="request-title">Audited financials FY2024</h1>
|
||||
<p class="request-meta">Project Alpha • Finance • Due: March 15, 2026</p>
|
||||
</div>
|
||||
<div class="request-priority">
|
||||
<span class="priority-dot high" aria-hidden="true"></span>
|
||||
<span class="priority-label">High Priority</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="request-content">
|
||||
<div class="request-main">
|
||||
<!-- Routing chain -->
|
||||
<section class="routing-section">
|
||||
<h2 class="section-title">Routing</h2>
|
||||
<div class="routing-box">
|
||||
<div class="routing-chain" role="navigation" aria-label="Request routing chain">
|
||||
<span class="routing-step">
|
||||
Acme Capital (Buyer)
|
||||
</span>
|
||||
<span class="routing-arrow" aria-hidden="true">→</span>
|
||||
<span class="routing-step">
|
||||
J. Smith (IB)
|
||||
</span>
|
||||
<span class="routing-arrow" aria-hidden="true">→</span>
|
||||
<span class="routing-step">
|
||||
M. Chen (CFO)
|
||||
</span>
|
||||
<span class="routing-arrow" aria-hidden="true">→</span>
|
||||
<span class="routing-step current">
|
||||
★ You
|
||||
</span>
|
||||
</div>
|
||||
<div class="routing-return">
|
||||
When done, returns to: <strong>M. Chen (CFO)</strong>
|
||||
</div>
|
||||
<div class="actions-bar">
|
||||
<button class="btn btn-primary">Mark Done</button>
|
||||
<button class="btn btn-secondary">Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Thread -->
|
||||
<section class="thread-section">
|
||||
<h2 class="section-title">Thread</h2>
|
||||
<div class="thread-list">
|
||||
<!-- Message 1: Original request -->
|
||||
<article class="thread-message">
|
||||
<header class="thread-message-header">
|
||||
<span class="thread-message-author">J. Smith (IB)</span>
|
||||
<time class="thread-message-time">Feb 25, 2026, 10:30 AM</time>
|
||||
</header>
|
||||
<div class="thread-message-body">
|
||||
<p>Please provide audited financial statements for FY2024.</p>
|
||||
<p>We need the following documents:</p>
|
||||
<ul>
|
||||
<li>Income statement (P&L)</li>
|
||||
<li>Balance sheet</li>
|
||||
<li>Cash flow statement</li>
|
||||
<li>Auditor's opinion letter</li>
|
||||
</ul>
|
||||
<p>Please ensure these are the final audited versions, not draft.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Message 2: CFO forwarding -->
|
||||
<article class="thread-message">
|
||||
<header class="thread-message-header">
|
||||
<span class="thread-message-author">M. Chen (CFO)</span>
|
||||
<time class="thread-message-time">Feb 26, 2026, 2:15 PM</time>
|
||||
</header>
|
||||
<div class="thread-message-body">
|
||||
<p>@accountant Can you pull these from the ERP? The audit was finalized last month.</p>
|
||||
<p>Attaching last year's format for reference so we maintain consistency.</p>
|
||||
</div>
|
||||
<a href="#" class="thread-message-attachment">
|
||||
📎 fy2023-financials-format.xlsx
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<!-- Message 3: Prior update -->
|
||||
<article class="thread-message">
|
||||
<header class="thread-message-header">
|
||||
<span class="thread-message-author">S. Johnson (You)</span>
|
||||
<time class="thread-message-time">Feb 27, 2026, 9:45 AM</time>
|
||||
</header>
|
||||
<div class="thread-message-body">
|
||||
<p>On it. Just waiting for the auditor to send the final signed opinion letter. Should have everything by EOD Thursday.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Response box -->
|
||||
<section class="response-section">
|
||||
<h2 class="section-title">Your Response</h2>
|
||||
<div class="response-box">
|
||||
<textarea
|
||||
class="textarea"
|
||||
placeholder="Write a message or upload files..."
|
||||
aria-label="Response message"
|
||||
></textarea>
|
||||
<div class="response-actions">
|
||||
<div class="response-tools">
|
||||
<button class="icon-btn" title="Attach file" aria-label="Attach file">
|
||||
📎
|
||||
</button>
|
||||
<button class="icon-btn" title="Format text" aria-label="Format text">
|
||||
𝐁
|
||||
</button>
|
||||
</div>
|
||||
<div class="response-submit">
|
||||
<button class="btn btn-secondary">Save Draft</button>
|
||||
<button class="btn btn-primary">Send & Mark Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="request-sidebar">
|
||||
<!-- Request details card -->
|
||||
<div class="info-card">
|
||||
<h3 class="info-card-title">Details</h3>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="status-badge assigned">Assigned</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Priority</span>
|
||||
<span class="info-value">High</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Due Date</span>
|
||||
<span class="info-value overdue">Mar 15, 2026 (Overdue)</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Workstream</span>
|
||||
<span class="info-value">Finance</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Created</span>
|
||||
<span class="info-value">Feb 25, 2026</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Assigned to</span>
|
||||
<span class="info-value">S. Johnson</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related requests card -->
|
||||
<div class="info-card">
|
||||
<h3 class="info-card-title">Related Requests</h3>
|
||||
<div style="padding: var(--space-sm) 0;">
|
||||
<a href="#" style="font-size: var(--text-sm); color: var(--color-text-link); text-decoration: none;">
|
||||
FIN-041 — Audited financials FY2023
|
||||
</a>
|
||||
<span class="status-badge published" style="margin-left: var(--space-sm);">Published</span>
|
||||
</div>
|
||||
<div style="padding: var(--space-sm) 0;">
|
||||
<a href="#" style="font-size: var(--text-sm); color: var(--color-text-link); text-decoration: none;">
|
||||
FIN-045 — Revenue recognition policy
|
||||
</a>
|
||||
<span class="status-badge assigned" style="margin-left: var(--space-sm);">Assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher (demo/testing only) -->
|
||||
<div class="theme-switcher" aria-label="Theme switcher">
|
||||
<button onclick="document.documentElement.className='theme-light'">Light</button>
|
||||
<button onclick="document.documentElement.className='theme-dark'">Dark</button>
|
||||
<button onclick="document.documentElement.className='theme-contrast'">Contrast</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="theme-light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Tasks — Dealspace</title>
|
||||
<link rel="stylesheet" href="../static/themes.css">
|
||||
<style>
|
||||
/* Page-specific layout */
|
||||
.page-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.inbox-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.inbox-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.view-select {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* Global nav area */
|
||||
.global-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.inbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-header-text);
|
||||
background: var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-header-text);
|
||||
}
|
||||
|
||||
/* Theme switcher (demo only) */
|
||||
.theme-switcher {
|
||||
position: fixed;
|
||||
bottom: var(--space-lg);
|
||||
right: var(--space-lg);
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-card-bg);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.theme-switcher button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-switcher button:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-layout">
|
||||
<!-- Global Header -->
|
||||
<header class="global-header">
|
||||
<div class="logo">Dealspace</div>
|
||||
|
||||
<!-- Project selector (hidden for single-project workers) -->
|
||||
<select class="project-select" aria-label="Select project">
|
||||
<option selected>Project Alpha — TargetCo Acquisition</option>
|
||||
<option>Project Beta — MergeCo Deal</option>
|
||||
<option>Project Gamma — TechStartup Buyout</option>
|
||||
</select>
|
||||
|
||||
<nav class="global-nav">
|
||||
<a href="/inbox" class="inbox-link" aria-current="page">
|
||||
Inbox
|
||||
<span class="badge">3</span>
|
||||
</a>
|
||||
<div class="user-menu">
|
||||
<div class="avatar" role="img" aria-label="User avatar"></div>
|
||||
<span class="user-name">S. Johnson</span>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content container">
|
||||
<div class="inbox-header">
|
||||
<h1 class="inbox-title">My Tasks</h1>
|
||||
<select class="view-select" aria-label="Filter tasks">
|
||||
<option selected>All Tasks</option>
|
||||
<option>Overdue</option>
|
||||
<option>Due Today</option>
|
||||
<option>Waiting</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="task-list" role="list">
|
||||
<!-- Task 1: High priority, overdue -->
|
||||
<a href="/p/alpha/r/fin-042" class="task-card" role="listitem">
|
||||
<div class="task-card-header">
|
||||
<span class="task-card-priority high" aria-label="High priority"></span>
|
||||
<span class="task-card-ref">FIN-042</span>
|
||||
<span class="task-card-title">Audited financials FY2024</span>
|
||||
<span class="task-card-due overdue">
|
||||
<span aria-hidden="true">⚠️</span> Overdue: Mar 15
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
Project Alpha • Finance • From: J. Smith (IB)
|
||||
</div>
|
||||
<div class="task-card-preview">
|
||||
"Please provide audited financial statements including P&L, balance sheet, and cash flow..."
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Task 2: Normal priority, due today -->
|
||||
<a href="/p/alpha/r/leg-018" class="task-card" role="listitem">
|
||||
<div class="task-card-header">
|
||||
<span class="task-card-priority normal" aria-label="Normal priority"></span>
|
||||
<span class="task-card-ref">LEG-018</span>
|
||||
<span class="task-card-title">Board meeting minutes 2025</span>
|
||||
<span class="task-card-due today">Due: Today</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
Project Alpha • Legal • From: M. Chen (CFO)
|
||||
</div>
|
||||
<div class="task-card-preview">
|
||||
"Board approval documentation needed for the acquisition committee review..."
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Task 3: Low priority, future -->
|
||||
<a href="/p/beta/r/it-007" class="task-card" role="listitem">
|
||||
<div class="task-card-header">
|
||||
<span class="task-card-priority low" aria-label="Low priority"></span>
|
||||
<span class="task-card-ref">IT-007</span>
|
||||
<span class="task-card-title">Network architecture diagram</span>
|
||||
<span class="task-card-due">Due: Mar 22</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
Project Beta • IT • From: L. Park (IB)
|
||||
</div>
|
||||
<div class="task-card-preview">
|
||||
"Provide a current network topology diagram showing all production systems..."
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Task 4: Normal priority, future -->
|
||||
<a href="/p/alpha/r/hr-003" class="task-card" role="listitem">
|
||||
<div class="task-card-header">
|
||||
<span class="task-card-priority normal" aria-label="Normal priority"></span>
|
||||
<span class="task-card-ref">HR-003</span>
|
||||
<span class="task-card-title">Employee headcount by department</span>
|
||||
<span class="task-card-due">Due: Mar 25</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
Project Alpha • HR • From: A. Williams (IB)
|
||||
</div>
|
||||
<div class="task-card-preview">
|
||||
"Breakdown of full-time and contract employees by department, including salary bands..."
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Task 5: High priority, future -->
|
||||
<a href="/p/alpha/r/fin-045" class="task-card" role="listitem">
|
||||
<div class="task-card-header">
|
||||
<span class="task-card-priority high" aria-label="High priority"></span>
|
||||
<span class="task-card-ref">FIN-045</span>
|
||||
<span class="task-card-title">Revenue recognition policy</span>
|
||||
<span class="task-card-due">Due: Mar 18</span>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
Project Alpha • Finance • From: J. Smith (IB)
|
||||
</div>
|
||||
<div class="task-card-preview">
|
||||
"Document your revenue recognition policy and any changes in the past 3 years..."
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher (demo/testing only) -->
|
||||
<div class="theme-switcher" aria-label="Theme switcher">
|
||||
<button onclick="document.documentElement.className='theme-light'">Light</button>
|
||||
<button onclick="document.documentElement.className='theme-dark'">Dark</button>
|
||||
<button onclick="document.documentElement.className='theme-contrast'">Contrast</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (hidden - shown when no tasks) -->
|
||||
<!--
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">✓</div>
|
||||
<h2 class="empty-state-title">You're all caught up</h2>
|
||||
<p class="empty-state-text">No tasks need your attention right now.</p>
|
||||
</div>
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue