From 37a1af4a1f1cfeece71b380c538535ec550e018b Mon Sep 17 00:00:00 2001 From: "Bernt (LandveX AI)" Date: Sun, 12 Jul 2026 17:17:26 +0000 Subject: [PATCH] =?UTF-8?q?style(boc):=20Landvex=20design=20system=20?= =?UTF-8?q?=E2=80=94=20no=20emojis,=20light=20theme,=20professional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced all emojis with SVG icons (Lucide-style) - Changed color scheme to Landvex blue (#0066FF) - Light theme background (#f5f5f7) - Professional typography (Inter, JetBrains Mono) - Clean, minimal design following Design Constitution - No decorative elements — information first - Consistent with Landvex Enterprise platform --- backend/boc | Bin 15540249 -> 15540249 bytes backend/stress_test.go | 274 +++++++++++ stress_test.go | 274 +++++++++++ web/assets/boc.css | 1068 +++++++++++++++++++++------------------- web/dashboard.html | 185 ++++--- web/index.html | 84 +++- web/login.html | 281 ++++++----- 7 files changed, 1468 insertions(+), 698 deletions(-) create mode 100644 backend/stress_test.go create mode 100644 stress_test.go diff --git a/backend/boc b/backend/boc index 374bc5029d639fc5f57d24e7187a06baf67bcd21..ce5257c55d0ea2edce9a7cdcfacd85862b2b000b 100755 GIT binary patch delta 1213 zcmbWy*Fy{d0D$qMbQB69WUo+eb$4ANjVr0Ht8`bPy6Z}(M3<%!iezMK7$JLxI5XLM zkL;OwdGZhV{0km_kKe;r)7&VmK|_{viNzS1sWyls^y)-Yp;(dWE9?-ei4%l3RaUnorTlRTs4lChSK75Y`Z)(x1zqv4bwM|DM-RB7Cwieb+|UPo(GTwEj{z8nK^Tl77>Z#Sju99M4~)WS zjDaV-Fcu<+A%PS!ctZ{al<7{VC#0 z(TG7TrXUVF=n;3Lpo+4!)kLjJNSDjlm>;$U=%B5;#8H? zJ5^>fn!II3AE_)|l`a>n45oCYQ6*7SX@#}!LYc2r;wurC`$d^C6Pd`uEM#Lg<{$?a z;Ju1@$Ok(C#=O5Y{fQgM;&%xCw8G8yRip*u@C!k00(ghhj9c)aSRPO zjuSYEMx4TFSaAktaSrEk0Tl<`j8$UPi7lGA~D^$~T9aQwt*E|D2Blk zUhu|n$l(J8lu*GJ0@Uz>2L1@Z2n1pzf)I>R7>y8wA`Iaeg9wbpIE+UmCLju0L_>!d z#9|`i5RU{TA_>Vz!6Zz^6imf5Oh+p8Fu({ACZu78y-8+n9qb`!45_}NT4U5JjiOR5 zngmhd>|O#WF0%3arE`tVS8uU@g|69P6 totalRequests/10 { + t.Fatalf("Too many errors: %d", errCount) + } +} + +// TestFullWorkflow - complete business flow +func TestFullWorkflow(t *testing.T) { + client := &http.Client{Timeout: 10 * time.Second} + + // 1. Login + loginPayload := map[string]string{ + "email": "erik@landvex.com", + "password": "password123", + } + body, _ := json.Marshal(loginPayload) + resp, err := client.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + var loginResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&loginResp) + resp.Body.Close() + + authToken, ok := loginResp["token"].(string) + if !ok { + t.Fatal("No token in response") + } + t.Logf("✓ Login successful") + + // 2. Create customer + customerPayload := map[string]interface{}{ + "name": "Stress Test AB", + "email": "stress@test.com", + "phone": "+46701234567", + "address": "Testgatan 1, Stockholm", + } + body, _ = json.Marshal(customerPayload) + req, _ := http.NewRequest("POST", baseURL+"/api/v1/crm/customers", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Create customer failed: %v", err) + } + var customerResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&customerResp) + resp.Body.Close() + customerID := customerResp["id"].(string) + t.Logf("✓ Customer created: %s", customerID) + + // 3. Create quote + quotePayload := map[string]interface{}{ + "customer_id": customerID, + "title": "Stress Test Quote", + "items": []map[string]interface{}{ + { + "description": "Test Product", + "quantity": 10, + "unit_price": 1000.00, + "tax_rate": 25.0, + }, + }, + } + body, _ = json.Marshal(quotePayload) + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Create quote failed: %v", err) + } + var quoteResp map[string]interface{} + json.NewDecoder(resp.Body).Decode("eResp) + resp.Body.Close() + quoteID := quoteResp["id"].(string) + t.Logf("✓ Quote created: %s", quoteID) + + // 4. Accept quote + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes/"+quoteID+"/accept", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Accept quote failed: %v", err) + } + resp.Body.Close() + t.Logf("✓ Quote accepted") + + // 5. Convert to order + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes/"+quoteID+"/convert", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Convert quote failed: %v", err) + } + var orderResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&orderResp) + resp.Body.Close() + t.Logf("✓ Quote converted to order: %s", orderResp["order_id"]) + + // 6. Get dashboard + req, _ = http.NewRequest("GET", baseURL+"/api/v1/analytics/dashboard", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Dashboard failed: %v", err) + } + resp.Body.Close() + t.Logf("✓ Dashboard loaded") + + t.Logf("\n=== WORKFLOW COMPLETE ===") +} + +// TestPDFGeneration - stress PDF generation +func TestPDFGeneration(t *testing.T) { + client := &http.Client{Timeout: 10 * time.Second} + + // Get existing quote + req, _ := http.NewRequest("GET", baseURL+"/api/v1/sales/quotes", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("List quotes failed: %v", err) + } + var quotesResp map[string]interface{} + json.NewDecoder(resp.Body).Decode("esResp) + resp.Body.Close() + + quotes := quotesResp["quotes"].([]interface{}) + if len(quotes) == 0 { + t.Skip("No quotes to test") + } + + quoteID := quotes[0].(map[string]interface{})["id"].(string) + + // Generate PDF + start := time.Now() + req, _ = http.NewRequest("GET", baseURL+"/api/v1/sales/quotes/"+quoteID+"/pdf", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("PDF generation failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("PDF generation returned %d", resp.StatusCode) + } + + duration := time.Since(start) + t.Logf("✓ PDF generated in %v (status: %d, content-type: %s)", duration, resp.StatusCode, resp.Header.Get("Content-Type")) +} diff --git a/stress_test.go b/stress_test.go new file mode 100644 index 000000000..d6f5b1f2b --- /dev/null +++ b/stress_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "sync" + "testing" + "time" +) + +const ( + baseURL = "http://localhost:9096" + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMjIyMjIyMjItMjIyMi0yMjIyLTIyMjItMjIyMjIyMjIyMjIyIiwiZW1haWwiOiJlcmlrQGxhbmR2ZXguY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzgyMzQ0MDAwfQ.demo" +) + +// BenchmarkHealthCheck - simple health endpoint +func BenchmarkHealthCheck(b *testing.B) { + for i := 0; i < b.N; i++ { + resp, err := http.Get(baseURL + "/health") + if err != nil { + b.Fatal(err) + } + resp.Body.Close() + } +} + +// BenchmarkLogin - auth endpoint +func BenchmarkLogin(b *testing.B) { + payload := map[string]string{ + "email": "erik@landvex.com", + "password": "password123", + } + body, _ := json.Marshal(payload) + + for i := 0; i < b.N; i++ { + resp, err := http.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + b.Fatal(err) + } + resp.Body.Close() + } +} + +// BenchmarkDashboard - protected endpoint with analytics +func BenchmarkDashboard(b *testing.B) { + req, _ := http.NewRequest("GET", baseURL+"/api/v1/analytics/dashboard", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 5 * time.Second} + + for i := 0; i < b.N; i++ { + resp, err := client.Do(req) + if err != nil { + b.Fatal(err) + } + resp.Body.Close() + } +} + +// BenchmarkQuotesList - database query +func BenchmarkQuotesList(b *testing.B) { + req, _ := http.NewRequest("GET", baseURL+"/api/v1/sales/quotes", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 5 * time.Second} + + for i := 0; i < b.N; i++ { + resp, err := client.Do(req) + if err != nil { + b.Fatal(err) + } + resp.Body.Close() + } +} + +// Concurrent load test +func TestConcurrentLoad(t *testing.T) { + concurrency := 50 + requests := 100 + + var wg sync.WaitGroup + errors := make(chan error, concurrency*requests) + start := time.Now() + + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + client := &http.Client{Timeout: 5 * time.Second} + + for j := 0; j < requests; j++ { + req, _ := http.NewRequest("GET", baseURL+"/health", nil) + resp, err := client.Do(req) + if err != nil { + errors <- fmt.Errorf("worker %d req %d: %v", worker, j, err) + continue + } + if resp.StatusCode != 200 { + errors <- fmt.Errorf("worker %d req %d: status %d", worker, j, resp.StatusCode) + } + resp.Body.Close() + } + }(i) + } + + wg.Wait() + close(errors) + + duration := time.Since(start) + totalRequests := concurrency * requests + rps := float64(totalRequests) / duration.Seconds() + + errCount := 0 + for err := range errors { + if errCount < 5 { + t.Logf("Error: %v", err) + } + errCount++ + } + + t.Logf("Total: %d requests in %v (%.0f req/sec)", totalRequests, duration, rps) + t.Logf("Errors: %d (%.2f%%)", errCount, float64(errCount)/float64(totalRequests)*100) + + if errCount > totalRequests/10 { + t.Fatalf("Too many errors: %d", errCount) + } +} + +// TestFullWorkflow - complete business flow +func TestFullWorkflow(t *testing.T) { + client := &http.Client{Timeout: 10 * time.Second} + + // 1. Login + loginPayload := map[string]string{ + "email": "erik@landvex.com", + "password": "password123", + } + body, _ := json.Marshal(loginPayload) + resp, err := client.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + var loginResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&loginResp) + resp.Body.Close() + + authToken, ok := loginResp["token"].(string) + if !ok { + t.Fatal("No token in response") + } + t.Logf("✓ Login successful") + + // 2. Create customer + customerPayload := map[string]interface{}{ + "name": "Stress Test AB", + "email": "stress@test.com", + "phone": "+46701234567", + "address": "Testgatan 1, Stockholm", + } + body, _ = json.Marshal(customerPayload) + req, _ := http.NewRequest("POST", baseURL+"/api/v1/crm/customers", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Create customer failed: %v", err) + } + var customerResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&customerResp) + resp.Body.Close() + customerID := customerResp["id"].(string) + t.Logf("✓ Customer created: %s", customerID) + + // 3. Create quote + quotePayload := map[string]interface{}{ + "customer_id": customerID, + "title": "Stress Test Quote", + "items": []map[string]interface{}{ + { + "description": "Test Product", + "quantity": 10, + "unit_price": 1000.00, + "tax_rate": 25.0, + }, + }, + } + body, _ = json.Marshal(quotePayload) + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Create quote failed: %v", err) + } + var quoteResp map[string]interface{} + json.NewDecoder(resp.Body).Decode("eResp) + resp.Body.Close() + quoteID := quoteResp["id"].(string) + t.Logf("✓ Quote created: %s", quoteID) + + // 4. Accept quote + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes/"+quoteID+"/accept", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Accept quote failed: %v", err) + } + resp.Body.Close() + t.Logf("✓ Quote accepted") + + // 5. Convert to order + req, _ = http.NewRequest("POST", baseURL+"/api/v1/sales/quotes/"+quoteID+"/convert", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Convert quote failed: %v", err) + } + var orderResp map[string]interface{} + json.NewDecoder(resp.Body).Decode(&orderResp) + resp.Body.Close() + t.Logf("✓ Quote converted to order: %s", orderResp["order_id"]) + + // 6. Get dashboard + req, _ = http.NewRequest("GET", baseURL+"/api/v1/analytics/dashboard", nil) + req.Header.Set("Authorization", "Bearer "+authToken) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("Dashboard failed: %v", err) + } + resp.Body.Close() + t.Logf("✓ Dashboard loaded") + + t.Logf("\n=== WORKFLOW COMPLETE ===") +} + +// TestPDFGeneration - stress PDF generation +func TestPDFGeneration(t *testing.T) { + client := &http.Client{Timeout: 10 * time.Second} + + // Get existing quote + req, _ := http.NewRequest("GET", baseURL+"/api/v1/sales/quotes", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("List quotes failed: %v", err) + } + var quotesResp map[string]interface{} + json.NewDecoder(resp.Body).Decode("esResp) + resp.Body.Close() + + quotes := quotesResp["quotes"].([]interface{}) + if len(quotes) == 0 { + t.Skip("No quotes to test") + } + + quoteID := quotes[0].(map[string]interface{})["id"].(string) + + // Generate PDF + start := time.Now() + req, _ = http.NewRequest("GET", baseURL+"/api/v1/sales/quotes/"+quoteID+"/pdf", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("PDF generation failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("PDF generation returned %d", resp.StatusCode) + } + + duration := time.Since(start) + t.Logf("✓ PDF generated in %v (status: %d, content-type: %s)", duration, resp.StatusCode, resp.Header.Get("Content-Type")) +} diff --git a/web/assets/boc.css b/web/assets/boc.css index 2c0ad764a..2824b9b14 100644 --- a/web/assets/boc.css +++ b/web/assets/boc.css @@ -1,31 +1,52 @@ +/* ============================================ + BOC — Business Operations Center + Design System: Landvex Enterprise + Light theme, no emojis, professional + ============================================ */ + :root { - /* Primary colors — warm terracotta, not cold blue */ - --primary: #C96A3A; - --primary-light: #E8845A; - --primary-dark: #A0502A; - - /* Semantic colors */ - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; - --info: #3b82f6; - - /* Backgrounds — warm, not sterile */ - --bg: #FAF9F7; - --surface: #FFFFFF; - --surface-hover: #F5F3F0; - --sidebar-bg: #1A1814; - + /* Brand — Landvex Blue */ + --brand: #0066FF; + --brand-dark: #0052CC; + --brand-light: #4D94FF; + --brand-glow: rgba(0, 102, 255, 0.15); + + /* Neutral Scale */ + --neutral-0: #ffffff; + --neutral-50: #f8f9fa; + --neutral-100: #f1f3f5; + --neutral-200: #e9ecef; + --neutral-300: #dee2e6; + --neutral-400: #ced4da; + --neutral-500: #adb5bd; + --neutral-600: #868e96; + --neutral-700: #495057; + --neutral-800: #343a40; + --neutral-900: #212529; + --neutral-1000: #0f172a; + + /* Semantic */ + --success: #40c057; + --warning: #fcc419; + --danger: #fa5252; + --info: #339af0; + + /* Surfaces */ + --bg: #f5f5f7; + --surface: #ffffff; + --surface-hover: #f8f9fa; + --sidebar-bg: #0f172a; + /* Text */ - --text: #1A1814; - --text-secondary: #6B6560; - --text-muted: #A09993; - --text-inverse: #FFFFFF; - + --text: #1d1d1f; + --text-secondary: #6B6B6B; + --text-muted: #868e96; + --text-inverse: #ffffff; + /* Borders */ - --border: #E8E4E0; - --border-strong: #D5CFC8; - + --border: rgba(0,0,0,0.08); + --border-strong: #e9ecef; + /* Spacing */ --space-xs: 4px; --space-sm: 8px; @@ -33,23 +54,27 @@ --space-lg: 24px; --space-xl: 32px; --space-2xl: 48px; - + /* Radius */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 24px; + /* Shadows */ --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); --shadow-md: 0 2px 8px rgba(0,0,0,0.06); - --shadow-lg: 0 4px 16px rgba(0,0,0,0.08); - + --shadow-lg: 0 8px 24px rgba(0,0,0,0.08); + /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif; - --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Helvetica Neue', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; } -* { margin: 0; padding: 0; box-sizing: border-box; } +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} body { font-family: var(--font-sans); @@ -57,17 +82,164 @@ body { color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -/* === LAYOUT === */ -.app { +/* ============================================ + LOGIN PAGE + ============================================ */ + +.login-container { min-height: 100vh; display: flex; + align-items: center; + justify-content: center; + background: var(--bg); } -/* === SIDEBAR === */ +.login-box { + background: var(--surface); + padding: 48px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 100%; + max-width: 400px; +} + +.login-logo { + width: 48px; + height: 48px; + background: var(--brand); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; +} + +.login-logo svg { + width: 24px; + height: 24px; + color: white; +} + +.login-box h1 { + font-size: 24px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.login-box .subtitle { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 32px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text); + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + font-size: 15px; + font-family: var(--font-sans); + color: var(--text); + background: var(--surface); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-group input:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-glow); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.btn-primary { + width: 100%; + padding: 14px; + background: var(--brand); + color: white; + border: none; + border-radius: var(--radius-md); + font-size: 15px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.btn-primary:hover { + background: var(--brand-dark); +} + +.btn-primary:active { + transform: scale(0.98); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.login-footer { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--border); + font-size: 13px; + color: var(--text-muted); + text-align: center; +} + +.login-footer a { + color: var(--brand); + text-decoration: none; + font-weight: 500; +} + +.error-msg { + background: #fff5f5; + color: var(--danger); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: 20px; + font-size: 14px; + border: 1px solid rgba(250, 82, 82, 0.2); + display: none; +} + +.error-msg.show { + display: block; +} + +/* ============================================ + APP LAYOUT + ============================================ */ + +.app { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ .sidebar { - width: 220px; + width: 240px; background: var(--sidebar-bg); color: var(--text-inverse); display: flex; @@ -80,117 +252,130 @@ body { } .sidebar-logo { - padding: var(--space-lg); - font-size: 18px; + padding: 24px; + font-size: 20px; font-weight: 700; + letter-spacing: -0.02em; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; align-items: center; - gap: var(--space-sm); + gap: 12px; +} + +.sidebar-logo svg { + width: 28px; + height: 28px; + color: var(--brand); } .sidebar-nav { flex: 1; - padding: var(--space-md) 0; - overflow-y: auto; + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 4px; } .sidebar-nav a { display: flex; align-items: center; - gap: var(--space-sm); - padding: 10px var(--space-lg); - color: rgba(255,255,255,0.55); + gap: 12px; + padding: 10px 16px; + border-radius: var(--radius-sm); + color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 500; - transition: all 0.15s ease; - border-left: 3px solid transparent; + transition: all 0.15s; } .sidebar-nav a:hover { - color: rgba(255,255,255,0.85); - background: rgba(255,255,255,0.04); + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.9); } .sidebar-nav a.active { - color: var(--primary-light); - background: rgba(201, 106, 58, 0.1); - border-left-color: var(--primary); + background: var(--brand); + color: white; } -.sidebar-nav .icon { - font-size: 18px; - width: 24px; - text-align: center; +.sidebar-nav svg { + width: 20px; + height: 20px; + opacity: 0.7; +} + +.sidebar-nav a.active svg { + opacity: 1; } .sidebar-footer { - padding: var(--space-md) var(--space-lg); + padding: 16px; border-top: 1px solid rgba(255,255,255,0.08); - font-size: 12px; + font-size: 13px; + color: rgba(255,255,255,0.4); } .sidebar-footer button { - margin-top: var(--space-sm); width: 100%; + margin-top: 12px; padding: 8px; - background: rgba(255,255,255,0.08); - color: var(--text-inverse); - border: none; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); border-radius: var(--radius-sm); + color: rgba(255,255,255,0.6); + font-size: 13px; cursor: pointer; - font-size: 12px; - transition: background 0.15s; + transition: all 0.15s; } .sidebar-footer button:hover { - background: rgba(255,255,255,0.15); + background: rgba(255,255,255,0.1); } -/* === MAIN CONTENT === */ +/* Main Content */ .main { flex: 1; - margin-left: 220px; - padding: var(--space-xl); + margin-left: 240px; + padding: 32px; max-width: 1400px; } .module-header { - margin-bottom: var(--space-xl); + margin-bottom: 32px; } .module-header h1 { - font-size: 28px; - font-weight: 700; - margin-bottom: var(--space-xs); + font-size: 30px; + font-weight: 600; letter-spacing: -0.02em; + color: var(--text); + margin-bottom: 8px; } -.subtitle { +.module-header .subtitle { + font-size: 14px; color: var(--text-secondary); - font-size: 15px; } -/* === KPI GRID === */ +/* ============================================ + KPI CARDS + ============================================ */ + .kpi-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--space-md); - margin-bottom: var(--space-xl); + grid-template-columns: repeat(6, 1fr); + gap: 16px; + margin-bottom: 32px; } .kpi-card { background: var(--surface); - border-radius: var(--radius-lg); - padding: var(--space-lg); - display: flex; - align-items: center; - gap: var(--space-md); - box-shadow: var(--shadow-sm); + border-radius: var(--radius-md); + padding: 20px; border: 1px solid var(--border); - transition: all 0.2s ease; cursor: pointer; + transition: box-shadow 0.15s, transform 0.1s; } .kpi-card:hover { @@ -199,251 +384,134 @@ body { } .kpi-icon { - font-size: 28px; - width: 48px; - height: 48px; + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--brand-glow); display: flex; align-items: center; justify-content: center; - background: var(--bg); - border-radius: var(--radius-md); + margin-bottom: 12px; +} + +.kpi-icon svg { + width: 20px; + height: 20px; + color: var(--brand); } .kpi-value { font-size: 24px; - font-weight: 700; + font-weight: 600; letter-spacing: -0.02em; + color: var(--text); + margin-bottom: 4px; } .kpi-label { - font-size: 12px; + font-size: 13px; color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; + margin-bottom: 8px; } .kpi-trend { font-size: 12px; - font-weight: 600; - margin-top: 2px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; } -.kpi-trend.up { color: var(--success); } -.kpi-trend.down { color: var(--danger); } +.kpi-trend.up { + color: var(--success); +} + +.kpi-trend.down { + color: var(--danger); +} + +/* ============================================ + PANELS + ============================================ */ -/* === PANELS === */ .panel { background: var(--surface); - border-radius: var(--radius-lg); - padding: var(--space-lg); - box-shadow: var(--shadow-sm); + border-radius: var(--radius-md); + padding: 24px; border: 1px solid var(--border); - margin-bottom: var(--space-lg); + margin-bottom: 24px; } .panel h3 { - font-size: 15px; + font-size: 16px; font-weight: 600; - margin-bottom: var(--space-md); color: var(--text); + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; } -/* === TOOLBAR === */ .toolbar { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--space-md); - gap: var(--space-md); + margin-bottom: 16px; } .toolbar h3 { margin: 0; } -/* === BUTTONS === */ -.btn-primary { - padding: 10px 18px; - background: var(--primary); - color: white; - border: none; - border-radius: var(--radius-md); - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; - display: inline-flex; - align-items: center; - gap: 6px; +/* ============================================ + DASHBOARD GRID + ============================================ */ + +.dashboard-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; } -.btn-primary:hover { - background: var(--primary-dark); - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} +/* ============================================ + QUICK ACTIONS + ============================================ */ -.btn-secondary { - padding: 10px 18px; - background: var(--bg); - color: var(--text); - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; -} - -.btn-secondary:hover { - background: var(--surface-hover); -} - -.btn-small { - padding: 6px 12px; - font-size: 12px; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - background: var(--primary); - color: white; - font-weight: 500; - transition: all 0.15s; -} - -.btn-small:hover { - background: var(--primary-dark); -} - -.btn-danger { - background: var(--danger); -} - -.btn-danger:hover { - background: #dc2626; -} - -.btn-link { - color: var(--primary); - text-decoration: none; - font-size: 14px; - font-weight: 600; -} - -.btn-link:hover { - text-decoration: underline; +.quick-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; } .btn-action { - padding: 12px 20px; - background: var(--bg); + padding: 12px; + background: var(--surface); border: 1px solid var(--border); - border-radius: var(--radius-md); - font-size: 14px; + border-radius: var(--radius-sm); + font-size: 13px; font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; color: var(--text); + cursor: pointer; + transition: all 0.15s; + font-family: var(--font-sans); } .btn-action:hover { - background: var(--primary); - color: white; - border-color: var(--primary); -} - -/* === DATA TABLES === */ -.data-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - font-size: 14px; -} - -.data-table th { - text-align: left; - padding: 12px; - border-bottom: 2px solid var(--border); - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - font-size: 11px; - letter-spacing: 0.5px; -} - -.data-table td { - padding: 14px 12px; - border-bottom: 1px solid var(--border); - vertical-align: middle; -} - -.data-table tr:hover td { background: var(--surface-hover); + border-color: var(--brand); + color: var(--brand); } -.data-table tr:last-child td { - border-bottom: none; -} +/* ============================================ + ACTIVITY FEED + ============================================ */ -/* === BADGES === */ -.badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; -} - -.badge::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 50%; -} - -.badge.active, .badge.completed, .badge.paid, .badge.ok { - background: #DCFCE7; - color: #166534; -} -.badge.active::before, .badge.completed::before, .badge.paid::before, .badge.ok::before { - background: var(--success); -} - -.badge.pending, .badge.warning, .badge.draft, .badge.sent { - background: #FEF3C7; - color: #92400E; -} -.badge.pending::before, .badge.warning::before, .badge.draft::before, .badge.sent::before { - background: var(--warning); -} - -.badge.failed, .badge.error, .badge.critical, .badge.overdue { - background: #FEE2E2; - color: #991B1B; -} -.badge.failed::before, .badge.error::before, .badge.critical::before, .badge.overdue::before { - background: var(--danger); -} - -.badge.open, .badge.running, .badge.processing { - background: #DBEAFE; - color: #1E40AF; -} -.badge.open::before, .badge.running::before, .badge.processing::before { - background: var(--info); -} - -/* === LISTS === */ .list-item { - padding: 14px 0; - border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; - align-items: center; - transition: background 0.15s; + align-items: flex-start; + padding: 12px 0; + border-bottom: 1px solid var(--border); } .list-item:last-child { @@ -451,154 +519,170 @@ body { } .list-item-main { - font-weight: 500; font-size: 14px; + color: var(--text); + margin-bottom: 4px; } .list-item-meta { - font-size: 13px; - color: var(--text-secondary); - margin-top: 2px; + font-size: 12px; + color: var(--text-muted); } -.list-item-right { - text-align: right; +.badge { + padding: 4px 10px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; } -/* === ALERTS === */ -.alerts-container { - position: fixed; - top: 0; - left: 220px; - right: 0; - z-index: 1000; - padding: var(--space-md) var(--space-xl); - background: var(--bg); +.badge.success { + background: rgba(64, 192, 87, 0.1); + color: var(--success); } -.alert { - padding: 14px 18px; - border-radius: var(--radius-md); - margin-bottom: var(--space-sm); - font-size: 14px; - display: flex; - align-items: center; - gap: var(--space-sm); +.badge.warning { + background: rgba(252, 196, 25, 0.1); + color: #d4a017; } -.alert-critical { - background: #FEE2E2; - color: #991B1B; - border-left: 4px solid var(--danger); +.badge.danger { + background: rgba(250, 82, 82, 0.1); + color: var(--danger); } -.alert-warning { - background: #FEF3C7; - color: #92400E; - border-left: 4px solid var(--warning); +.badge.info { + background: rgba(51, 154, 240, 0.1); + color: var(--info); } -.alert-info { - background: #DBEAFE; - color: #1E40AF; - border-left: 4px solid var(--info); -} - -.alert-success { - background: #DCFCE7; - color: #166534; - border-left: 4px solid var(--success); -} - -/* === AUTOMATION STATUS === */ -.automation-status { - margin-bottom: var(--space-lg); -} +/* ============================================ + AUTOMATION STATUS + ============================================ */ .automation-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--space-md); + display: flex; + flex-direction: column; + gap: 12px; } .automation-item { display: flex; align-items: center; - gap: var(--space-md); - padding: 14px var(--space-md); - background: var(--bg); - border-radius: var(--radius-md); - font-size: 14px; - border: 1px solid var(--border); - transition: all 0.15s; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border); } -.automation-item:hover { - border-color: var(--border-strong); +.automation-item:last-child { + border-bottom: none; } .automation-indicator { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; - position: relative; } .automation-indicator.active { background: var(--success); - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.15); } .automation-indicator.warning { background: var(--warning); - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.15); } -.automation-indicator.error { +.automation-indicator.danger { background: var(--danger); - box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15); } .automation-name { - font-weight: 600; + font-size: 14px; + font-weight: 500; + color: var(--text); flex: 1; } .automation-schedule { font-size: 12px; - color: var(--text-secondary); + color: var(--text-muted); } -/* === QUICK ACTIONS === */ -.quick-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-sm); +/* ============================================ + ALERTS + ============================================ */ + +.alert { + padding: 16px 20px; + border-radius: var(--radius-md); + font-size: 14px; + margin-bottom: 16px; } -/* === MODAL === */ +.alert-critical { + background: rgba(250, 82, 82, 0.06); + border: 1px solid rgba(250, 82, 82, 0.15); + color: var(--danger); +} + +.alert-warning { + background: rgba(252, 196, 25, 0.06); + border: 1px solid rgba(252, 196, 25, 0.15); + color: #d4a017; +} + +/* ============================================ + LIVE INDICATOR + ============================================ */ + +#live-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--success); +} + +#live-indicator::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ============================================ + MODAL + ============================================ */ + .modal { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0,0,0,0.5); + inset: 0; + background: rgba(15, 23, 42, 0.5); display: flex; align-items: center; justify-content: center; - z-index: 2000; + z-index: 300; backdrop-filter: blur(4px); } .modal-content { background: var(--surface); border-radius: var(--radius-lg); - width: 90%; - max-width: 520px; - max-height: 85vh; - overflow-y: auto; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow: auto; box-shadow: var(--shadow-lg); } @@ -606,188 +690,160 @@ body { display: flex; justify-content: space-between; align-items: center; - padding: var(--space-lg); - border-bottom: 1px solid var(--border); + padding: 24px 24px 0; } .modal-header h3 { - margin: 0; font-size: 18px; + font-weight: 600; } .modal-close { background: none; border: none; font-size: 24px; + color: var(--text-muted); cursor: pointer; - color: var(--text-secondary); - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - transition: background 0.15s; + padding: 4px; + line-height: 1; } .modal-close:hover { - background: var(--bg); + color: var(--text); } .modal-body { - padding: var(--space-lg); + padding: 24px; } -/* === FORMS === */ -.form-group { - margin-bottom: var(--space-md); -} +/* ============================================ + EMPTY STATE + ============================================ */ -.form-group label { - display: block; - margin-bottom: 6px; - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.form-group input, -.form-group select, -.form-group textarea { - width: 100%; - padding: 12px 14px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - font-size: 15px; - font-family: inherit; - background: var(--surface); - color: var(--text); - transition: all 0.15s; -} - -.form-group input:focus, -.form-group select:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(201, 106, 58, 0.1); -} - -.form-group input::placeholder { +.empty-state { + text-align: center; + padding: 48px 24px; color: var(--text-muted); } -/* === CODE === */ -code { - background: var(--bg); - padding: 2px 6px; - border-radius: 4px; - font-family: var(--font-mono); - font-size: 12px; - color: var(--text-secondary); -} - -/* === CHARTS === */ -canvas { - max-height: 300px; -} - -/* === EMPTY STATE === */ -.empty-state { - text-align: center; - padding: var(--space-2xl); - color: var(--text-secondary); -} - .empty-state-icon { - font-size: 48px; - margin-bottom: var(--space-md); + font-size: 32px; + margin-bottom: 12px; opacity: 0.5; } -/* === LOADING === */ +/* ============================================ + LOADING + ============================================ */ + .loading { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-xl); - color: var(--text-secondary); + text-align: center; + padding: 24px; + color: var(--text-muted); } -.loading::after { - content: ''; - width: 20px; - height: 20px; - border: 2px solid var(--border); - border-top-color: var(--primary); +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-strong); + border-top-color: var(--brand); border-radius: 50%; - margin-left: var(--space-sm); animation: spin 0.8s linear infinite; + margin: 0 auto 12px; } @keyframes spin { to { transform: rotate(360deg); } } -/* === RESPONSIVE === */ -@media (max-width: 768px) { - .sidebar { - width: 60px; +/* ============================================ + RESPONSIVE + ============================================ */ + +@media (max-width: 1200px) { + .kpi-grid { + grid-template-columns: repeat(3, 1fr); } - .sidebar-logo { - font-size: 14px; - padding: var(--space-md) var(--space-sm); - justify-content: center; - } - .sidebar-logo span:last-child { - display: none; - } - .sidebar-nav a { - padding: 12px; - justify-content: center; - } - .sidebar-nav a span:not(.icon) { - display: none; - } - .sidebar-footer { - display: none; - } - .main { - margin-left: 60px; - padding: var(--space-md); - } - .kpi-grid { - grid-template-columns: repeat(2, 1fr); - gap: var(--space-sm); - } - .kpi-card { - padding: var(--space-md); - } - .kpi-value { - font-size: 20px; - } - .dashboard-grid { - grid-template-columns: 1fr; - } - .automation-grid { - grid-template-columns: 1fr; - } - .toolbar { - flex-direction: column; - align-items: stretch; - } - .quick-actions { - justify-content: stretch; - } - .btn-action { - flex: 1; - text-align: center; + .dashboard-grid { + grid-template-columns: 1fr; } } -/* === SCROLLBAR === */ +@media (max-width: 768px) { + .sidebar { + width: 100%; + transform: translateX(-100%); + transition: transform 0.3s; + } + .sidebar.open { + transform: translateX(0); + } + .main { + margin-left: 0; + padding: 16px; + } + .kpi-grid { + grid-template-columns: repeat(2, 1fr); + } + .quick-actions { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ============================================ + TABLES + ============================================ */ + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.data-table th { + text-align: left; + padding: 12px 16px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + background: var(--neutral-50); +} + +.data-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + color: var(--text); +} + +.data-table tr:hover td { + background: var(--surface-hover); +} + +/* ============================================ + BUTTONS + ============================================ */ + +.btn-link { + font-size: 13px; + font-weight: 500; + color: var(--brand); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.btn-link:hover { + text-decoration: underline; +} + +/* ============================================ + SCROLLBAR + ============================================ */ + ::-webkit-scrollbar { width: 8px; height: 8px; @@ -798,22 +854,10 @@ canvas { } ::-webkit-scrollbar-thumb { - background: var(--border-strong); + background: var(--neutral-300); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} - -/* === FOCUS VISIBLE === */ -:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* === SELECTION === */ -::selection { - background: rgba(201, 106, 58, 0.2); - color: var(--text); + background: var(--neutral-400); } diff --git a/web/dashboard.html b/web/dashboard.html index 12987ebca..e3d9b0b2d 100644 --- a/web/dashboard.html +++ b/web/dashboard.html @@ -11,17 +11,50 @@