security: Add proper authentication, RBAC, and tenant isolation
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
This commit is contained in:
+1
File diff suppressed because one or more lines are too long
Vendored
+763
File diff suppressed because one or more lines are too long
Vendored
+397
@@ -0,0 +1,397 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BOC Banking Test</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #1a1a1a; margin-bottom: 24px; }
|
||||
h2 { color: #333; margin: 32px 0 16px; font-size: 18px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
.tab.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.bank-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bank-card {
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.bank-card.revolut { background: #eff6ff; border-color: #bfdbfe; }
|
||||
.bank-card.nordea { background: #fef2f2; border-color: #fecaca; }
|
||||
|
||||
.bank-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bank-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.bank-name { font-weight: 600; color: #1a1a1a; }
|
||||
.bank-number { font-size: 13px; color: #666; }
|
||||
|
||||
.balance {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.transaction {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.transaction-info { display: flex; align-items: center; gap: 12px; }
|
||||
.transaction-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.credit { background: #dcfce7; color: #166534; }
|
||||
.debit { background: #fee2e2; color: #991b1b; }
|
||||
.transaction-desc { font-weight: 500; color: #1a1a1a; font-size: 14px; }
|
||||
.transaction-meta { font-size: 12px; color: #666; margin-top: 2px; }
|
||||
.transaction-amount { font-weight: 600; font-size: 14px; }
|
||||
.amount-credit { color: #166534; }
|
||||
.amount-debit { color: #991b1b; }
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-primary { background: #2563eb; color: white; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.upload-area:hover {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.status-ok { color: #166534; }
|
||||
.status-warn { color: #92400e; }
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🏦 Accounting - Bankkoppling</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('banks')">🏦 Bankkonton</button>
|
||||
<button class="tab" onclick="showTab('transactions')">💸 Transaktioner</button>
|
||||
<button class="tab" onclick="showTab('ledger')">📖 Huvudbok</button>
|
||||
<button class="tab" onclick="showTab('accounts')">📋 Konton</button>
|
||||
</div>
|
||||
|
||||
<!-- Bankkonton -->
|
||||
<div id="banks" class="tab-content">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2>Dina bankkonton</h2>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('upload').classList.toggle('hidden')">
|
||||
📤 Ladda upp kontoutdrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="upload" class="card hidden">
|
||||
<h3 style="margin-bottom:16px;">Ladda upp kontoutdrag</h3>
|
||||
<div class="upload-area" onclick="alert('Filuppladdning skulle öppnas här')">
|
||||
<div style="font-size:32px; margin-bottom:12px;">📁</div>
|
||||
<p>Dra och släpp fil här, eller <strong>klicka för att välja</strong></p>
|
||||
<p style="font-size:12px; color:#666; margin-top:8px;">Stödjer CSV, PDF, XLSX</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-grid">
|
||||
<div class="bank-card revolut">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔵</div>
|
||||
<div>
|
||||
<div class="bank-name">Revolut Business</div>
|
||||
<div class="bank-number">1234 5678 9012 3456</div>
|
||||
<div style="font-size:11px; color:#666;">IBAN: GB29 NWBK 6016 1331 9268 19</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">125 430,50 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Revolut API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-card nordea">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔴</div>
|
||||
<div>
|
||||
<div class="bank-name">Nordea Företagskonto</div>
|
||||
<div class="bank-number">3456 7890 1234 5678</div>
|
||||
<div style="font-size:11px; color:#666;">IBAN: SE45 5000 0000 0583 9825 7466</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">89 200,00 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Nordea API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-card nordea">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔴</div>
|
||||
<div>
|
||||
<div class="bank-name">Nordea Sparkonto</div>
|
||||
<div class="bank-number">9876 5432 1098 7654</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">250 000,00 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Nordea API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:32px;">📄 Importerade kontoutdrag</h2>
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">📄</div>
|
||||
<div>
|
||||
<div class="transaction-desc">revolut_2026-07.csv</div>
|
||||
<div class="transaction-meta">2026-07-01 — 2026-07-31 • 45 transaktioner</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-ok">✅ Klart</span>
|
||||
</div>
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">📄</div>
|
||||
<div>
|
||||
<div class="transaction-desc">nordea_juli_2026.pdf</div>
|
||||
<div class="transaction-meta">2026-07-01 — 2026-07-31 • 32 transaktioner</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-ok">✅ Klart</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaktioner -->
|
||||
<div id="transactions" class="tab-content hidden">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2>Senaste transaktioner</h2>
|
||||
<select style="padding:8px 12px; border-radius:8px; border:1px solid #e0e0e0;">
|
||||
<option>Alla konton</option>
|
||||
<option>Revolut Business</option>
|
||||
<option>Nordea Företagskonto</option>
|
||||
<option>Nordea Sparkonto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Atlas Capture AB - Månadsavgift</div>
|
||||
<div class="transaction-meta">2026-08-08 • Atlas Capture AB • Programvara</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-299,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">↙️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Kundbetalning - Faktura #1001</div>
|
||||
<div class="transaction-meta">2026-08-07 • Kund AB • Försäljning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-credit">+15 000,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Lön - Johan Berglund</div>
|
||||
<div class="transaction-meta">2026-08-06 • Johan Berglund • Lönekostnad</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-45 000,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Hyra kontor - Wavult Group</div>
|
||||
<div class="transaction-meta">2026-08-05 • Wavult Group • Lokalhyra</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-8 500,00 kr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Huvudbok -->
|
||||
<div id="ledger" class="tab-content hidden">
|
||||
<h2>Huvudbok</h2>
|
||||
<div class="card">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e0e0e0;">
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Ver.nr</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Beskrivning</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Datum</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Period</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1</td>
|
||||
<td style="padding:12px;">Inbetalning från kund</td>
|
||||
<td style="padding:12px; color:#666;">2026-08-01</td>
|
||||
<td style="padding:12px; color:#666;">2026-08</td>
|
||||
<td style="padding:12px;"><span style="background:#dcfce7; color:#166534; padding:4px 8px; border-radius:4px; font-size:12px;">Bokförd</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">2</td>
|
||||
<td style="padding:12px;">Leverantörsfaktura</td>
|
||||
<td style="padding:12px; color:#666;">2026-08-02</td>
|
||||
<td style="padding:12px; color:#666;">2026-08</td>
|
||||
<td style="padding:12px;"><span style="background:#dcfce7; color:#166534; padding:4px 8px; border-radius:4px; font-size:12px;">Bokförd</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konton -->
|
||||
<div id="accounts" class="tab-content hidden">
|
||||
<h2>Konton</h2>
|
||||
<div class="card">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e0e0e0;">
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Kod</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Namn</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Typ</th>
|
||||
<th style="text-align:right; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1510</td>
|
||||
<td style="padding:12px;">Kundfordringar</td>
|
||||
<td style="padding:12px; color:#666;">Tillgång</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">25 000,00 kr</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1930</td>
|
||||
<td style="padding:12px;">Företagskonto</td>
|
||||
<td style="padding:12px; color:#666;">Tillgång</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">89 200,00 kr</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">2013</td>
|
||||
<td style="padding:12px;">Egna insättningar</td>
|
||||
<td style="padding:12px; color:#666;">Eget kapital</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">100 000,00 kr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(tabId) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,397 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BOC Banking Test</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #1a1a1a; margin-bottom: 24px; }
|
||||
h2 { color: #333; margin: 32px 0 16px; font-size: 18px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
.tab.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.bank-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bank-card {
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.bank-card.revolut { background: #eff6ff; border-color: #bfdbfe; }
|
||||
.bank-card.nordea { background: #fef2f2; border-color: #fecaca; }
|
||||
|
||||
.bank-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bank-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.bank-name { font-weight: 600; color: #1a1a1a; }
|
||||
.bank-number { font-size: 13px; color: #666; }
|
||||
|
||||
.balance {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.transaction {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.transaction-info { display: flex; align-items: center; gap: 12px; }
|
||||
.transaction-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.credit { background: #dcfce7; color: #166534; }
|
||||
.debit { background: #fee2e2; color: #991b1b; }
|
||||
.transaction-desc { font-weight: 500; color: #1a1a1a; font-size: 14px; }
|
||||
.transaction-meta { font-size: 12px; color: #666; margin-top: 2px; }
|
||||
.transaction-amount { font-weight: 600; font-size: 14px; }
|
||||
.amount-credit { color: #166534; }
|
||||
.amount-debit { color: #991b1b; }
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-primary { background: #2563eb; color: white; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.upload-area:hover {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.status-ok { color: #166534; }
|
||||
.status-warn { color: #92400e; }
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🏦 Accounting - Bankkoppling</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('banks')">🏦 Bankkonton</button>
|
||||
<button class="tab" onclick="showTab('transactions')">💸 Transaktioner</button>
|
||||
<button class="tab" onclick="showTab('ledger')">📖 Huvudbok</button>
|
||||
<button class="tab" onclick="showTab('accounts')">📋 Konton</button>
|
||||
</div>
|
||||
|
||||
<!-- Bankkonton -->
|
||||
<div id="banks" class="tab-content">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2>Dina bankkonton</h2>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('upload').classList.toggle('hidden')">
|
||||
📤 Ladda upp kontoutdrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="upload" class="card hidden">
|
||||
<h3 style="margin-bottom:16px;">Ladda upp kontoutdrag</h3>
|
||||
<div class="upload-area" onclick="alert('Filuppladdning skulle öppnas här')">
|
||||
<div style="font-size:32px; margin-bottom:12px;">📁</div>
|
||||
<p>Dra och släpp fil här, eller <strong>klicka för att välja</strong></p>
|
||||
<p style="font-size:12px; color:#666; margin-top:8px;">Stödjer CSV, PDF, XLSX</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-grid">
|
||||
<div class="bank-card revolut">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔵</div>
|
||||
<div>
|
||||
<div class="bank-name">Revolut Business</div>
|
||||
<div class="bank-number">1234 5678 9012 3456</div>
|
||||
<div style="font-size:11px; color:#666;">IBAN: GB29 NWBK 6016 1331 9268 19</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">125 430,50 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Revolut API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-card nordea">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔴</div>
|
||||
<div>
|
||||
<div class="bank-name">Nordea Företagskonto</div>
|
||||
<div class="bank-number">3456 7890 1234 5678</div>
|
||||
<div style="font-size:11px; color:#666;">IBAN: SE45 5000 0000 0583 9825 7466</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">89 200,00 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Nordea API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bank-card nordea">
|
||||
<div class="bank-header">
|
||||
<div class="bank-icon">🔴</div>
|
||||
<div>
|
||||
<div class="bank-name">Nordea Sparkonto</div>
|
||||
<div class="bank-number">9876 5432 1098 7654</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-label">Saldo</div>
|
||||
<div class="balance">250 000,00 kr</div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px;">
|
||||
<button class="btn btn-secondary" onclick="alert('Kopplar Nordea API...')">🔗 Koppla API</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:32px;">📄 Importerade kontoutdrag</h2>
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">📄</div>
|
||||
<div>
|
||||
<div class="transaction-desc">revolut_2026-07.csv</div>
|
||||
<div class="transaction-meta">2026-07-01 — 2026-07-31 • 45 transaktioner</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-ok">✅ Klart</span>
|
||||
</div>
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">📄</div>
|
||||
<div>
|
||||
<div class="transaction-desc">nordea_juli_2026.pdf</div>
|
||||
<div class="transaction-meta">2026-07-01 — 2026-07-31 • 32 transaktioner</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-ok">✅ Klart</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaktioner -->
|
||||
<div id="transactions" class="tab-content hidden">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||
<h2>Senaste transaktioner</h2>
|
||||
<select style="padding:8px 12px; border-radius:8px; border:1px solid #e0e0e0;">
|
||||
<option>Alla konton</option>
|
||||
<option>Revolut Business</option>
|
||||
<option>Nordea Företagskonto</option>
|
||||
<option>Nordea Sparkonto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Atlas Capture AB - Månadsavgift</div>
|
||||
<div class="transaction-meta">2026-08-08 • Atlas Capture AB • Programvara</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-299,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon credit">↙️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Kundbetalning - Faktura #1001</div>
|
||||
<div class="transaction-meta">2026-08-07 • Kund AB • Försäljning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-credit">+15 000,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Lön - Johan Berglund</div>
|
||||
<div class="transaction-meta">2026-08-06 • Johan Berglund • Lönekostnad</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-45 000,00 kr</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction">
|
||||
<div class="transaction-info">
|
||||
<div class="transaction-icon debit">↗️</div>
|
||||
<div>
|
||||
<div class="transaction-desc">Hyra kontor - Wavult Group</div>
|
||||
<div class="transaction-meta">2026-08-05 • Wavult Group • Lokalhyra</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transaction-amount amount-debit">-8 500,00 kr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Huvudbok -->
|
||||
<div id="ledger" class="tab-content hidden">
|
||||
<h2>Huvudbok</h2>
|
||||
<div class="card">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e0e0e0;">
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Ver.nr</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Beskrivning</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Datum</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Period</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1</td>
|
||||
<td style="padding:12px;">Inbetalning från kund</td>
|
||||
<td style="padding:12px; color:#666;">2026-08-01</td>
|
||||
<td style="padding:12px; color:#666;">2026-08</td>
|
||||
<td style="padding:12px;"><span style="background:#dcfce7; color:#166534; padding:4px 8px; border-radius:4px; font-size:12px;">Bokförd</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">2</td>
|
||||
<td style="padding:12px;">Leverantörsfaktura</td>
|
||||
<td style="padding:12px; color:#666;">2026-08-02</td>
|
||||
<td style="padding:12px; color:#666;">2026-08</td>
|
||||
<td style="padding:12px;"><span style="background:#dcfce7; color:#166534; padding:4px 8px; border-radius:4px; font-size:12px;">Bokförd</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konton -->
|
||||
<div id="accounts" class="tab-content hidden">
|
||||
<h2>Konton</h2>
|
||||
<div class="card">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e0e0e0;">
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Kod</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Namn</th>
|
||||
<th style="text-align:left; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Typ</th>
|
||||
<th style="text-align:right; padding:12px; font-size:12px; text-transform:uppercase; color:#666;">Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1510</td>
|
||||
<td style="padding:12px;">Kundfordringar</td>
|
||||
<td style="padding:12px; color:#666;">Tillgång</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">25 000,00 kr</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">1930</td>
|
||||
<td style="padding:12px;">Företagskonto</td>
|
||||
<td style="padding:12px; color:#666;">Tillgång</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">89 200,00 kr</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f0f0f0;">
|
||||
<td style="padding:12px; font-weight:500;">2013</td>
|
||||
<td style="padding:12px;">Egna insättningar</td>
|
||||
<td style="padding:12px; color:#666;">Eget kapital</td>
|
||||
<td style="padding:12px; text-align:right; font-weight:500;">100 000,00 kr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(tabId) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
interface AgentButtonProps {
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function AgentButton({ onClick, isActive }: AgentButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all ${
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||
: 'text-text-secondary hover:bg-bg hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span className="font-medium">AI Assistant</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Send, Bot, User, X, Minimize2, Maximize2, Sparkles } from 'lucide-react';
|
||||
import { getAgentContext, AgentContext } from './AgentContext';
|
||||
|
||||
interface Meddelande {
|
||||
id: string;
|
||||
roll: 'user' | 'assistant';
|
||||
innehall: string;
|
||||
tid: string;
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
rum: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function AgentChat({ rum, isOpen, onToggle }: AgentChatProps) {
|
||||
const context = getAgentContext(rum);
|
||||
const [meddelanden, setMeddelanden] = useState<Meddelande[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
roll: 'assistant',
|
||||
innehall: `Hej! Jag är ${context.titel}. Jag kan hjälpa dig med ${context.kompetenser.join(', ')}. Vad kan jag göra för dig?`,
|
||||
tid: new Date().toISOString()
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [laddar, setLaddar] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [meddelanden]);
|
||||
|
||||
const skickaMeddelande = async () => {
|
||||
if (!input.trim() || laddar) return;
|
||||
|
||||
const userMeddelande: Meddelande = {
|
||||
id: `user-${Date.now()}`,
|
||||
roll: 'user',
|
||||
innehall: input.trim(),
|
||||
tid: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMeddelanden(prev => [...prev, userMeddelande]);
|
||||
setInput('');
|
||||
setLaddar(true);
|
||||
|
||||
try {
|
||||
// Anropa Claude via OpenClaw API
|
||||
const response = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rum: context.rum,
|
||||
systemPrompt: context.systemPrompt,
|
||||
meddelanden: [...meddelanden, userMeddelande].map(m => ({
|
||||
roll: m.roll,
|
||||
innehall: m.innehall
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: data.svar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
} else {
|
||||
// Fallback: Mock-svar
|
||||
const mockSvar = genereraMockSvar(context, input.trim());
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: mockSvar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
const mockSvar = genereraMockSvar(context, input.trim());
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: mockSvar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
}
|
||||
|
||||
setLaddar(false);
|
||||
};
|
||||
|
||||
const hanteraKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
skickaMeddelande();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-primary rounded-full shadow-lg flex items-center justify-center text-white hover:bg-primary/90 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Sparkles size={24} />
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop - click to close */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
className={`fixed right-0 top-0 h-full bg-surface border-l border-border z-50 flex flex-col shadow-xl ${
|
||||
isExpanded ? 'w-[500px]' : 'w-[380px]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-border flex items-center justify-between px-4 bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<Bot size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{context.titel}</h3>
|
||||
<p className="text-xs text-text-secondary">AI Assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-2 hover:bg-bg rounded-lg text-text-secondary"
|
||||
>
|
||||
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-bg rounded-lg text-text-secondary"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{meddelanden.map((meddelande) => (
|
||||
<div
|
||||
key={meddelande.id}
|
||||
className={`flex gap-3 ${meddelande.roll === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||
meddelande.roll === 'assistant'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-bg text-text-secondary'
|
||||
}`}>
|
||||
{meddelande.roll === 'assistant' ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm ${
|
||||
meddelande.roll === 'assistant'
|
||||
? 'bg-bg text-text-primary rounded-tl-none'
|
||||
: 'bg-primary text-white rounded-tr-none'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap">{meddelande.innehall}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{laddar && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
|
||||
<Bot size={16} />
|
||||
</div>
|
||||
<div className="bg-bg rounded-2xl rounded-tl-none px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce delay-100" />
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce delay-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={hanteraKeyDown}
|
||||
placeholder="Skriv ett meddelande..."
|
||||
className="flex-1 min-h-[44px] max-h-[120px] px-4 py-2.5 rounded-xl border bg-surface text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={skickaMeddelande}
|
||||
disabled={!input.trim() || laddar}
|
||||
className="w-11 h-11 bg-primary text-white rounded-xl flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-2 text-center">
|
||||
AI kan göra misstag. Verifiera viktig information.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function genereraMockSvar(context: AgentContext, input: string): string {
|
||||
const inputLower = input.toLowerCase();
|
||||
|
||||
if (context.rum === 'finance') {
|
||||
if (inputLower.includes('moms')) {
|
||||
return 'MOMS (mervärdesskatt) ska redovisas månadsvis eller kvartalsvis beroende på företagets omsättning. Nuvarande MOMS-att-betala är 336 006 kr för perioden. Vill du se detaljerad MOMS-rapport?';
|
||||
}
|
||||
if (inputLower.includes('faktura') || inputLower.includes('invoice')) {
|
||||
return 'Det finns för närvarande 3 fakturor som väntar på betalning. Faktura #INV-2024-0042 är 3 dagar försenad. Vill du skicka en påminnelse?';
|
||||
}
|
||||
if (inputLower.includes('balans')) {
|
||||
return 'Totala tillgångar är 1 842 278 kr, skulder 435 657 kr och eget kapital 1 406 621 kr. Soliditeten är 76.4% vilket är mycket starkt.';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med finansiell analys, MOMS-rapportering, fakturahantering och kassaflödesprognoser. Vad vill du veta mer om?';
|
||||
}
|
||||
|
||||
if (context.rum === 'sales') {
|
||||
if (inputLower.includes('lead')) {
|
||||
return 'Just nu har vi 12 aktiva leads i pipelinen. 3 är i förhandlingsfas och beräknas stängas denna månad. Vill du se detaljerad pipeline?';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med lead-hantering, offertförfrågningar och säljrapporter. Vad behöver du hjälp med?';
|
||||
}
|
||||
|
||||
if (context.rum === 'hr') {
|
||||
if (inputLower.includes('semester')) {
|
||||
return 'Du har 25 semesterdagar kvar att ta ut i år. Nästa planerade semester är vecka 32. Vill du ansöka om ny semester?';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med HR-frågor, semesterplanering och personalärenden. Vad kan jag göra för dig?';
|
||||
}
|
||||
|
||||
return `Jag förstår. Som ${context.titel} kan jag hjälpa dig med ${context.kompetenser.join(', ')}. Kan du specificera vad du behöver hjälp med?`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export interface AgentContext {
|
||||
rum: string; // 'finance', 'sales', 'hr', etc.
|
||||
titel: string;
|
||||
systemPrompt: string;
|
||||
kompetenser: string[];
|
||||
dataTyp: string; // Vilken typ av data agenten har tillgång till
|
||||
}
|
||||
|
||||
export const agentContexts: Record<string, AgentContext> = {
|
||||
finance: {
|
||||
rum: 'finance',
|
||||
titel: 'Finance AI',
|
||||
systemPrompt: `Du är en expert på finans och redovisning. Du hjälper användaren med:
|
||||
- Analys av balansräkning och resultaträkning
|
||||
- MOMS-rapportering och skattefrågor
|
||||
- Fakturahantering och betalningspåminnelser
|
||||
- Kassaflödesanalys och prognoser
|
||||
- Bokslut och årsredovisning
|
||||
|
||||
Du har tillgång till företagets finansiella data i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var professionell, noggrann och hjälpsam.`,
|
||||
kompetenser: ['redovisning', 'finansanalys', 'skatt', 'fakturering'],
|
||||
dataTyp: 'financial'
|
||||
},
|
||||
sales: {
|
||||
rum: 'sales',
|
||||
titel: 'Sales AI',
|
||||
systemPrompt: `Du är en expert på försäljning och CRM. Du hjälper användaren med:
|
||||
- Lead-hantering och kvalificering
|
||||
- Offertförfrågningar och prissättning
|
||||
- Säljrapporter och pipeline-analys
|
||||
- Kundkommunikation och uppföljning
|
||||
- Säljstrategi och marknadsanalys
|
||||
|
||||
Du har tillgång till CRM-data och säljstatistik i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.`,
|
||||
kompetenser: ['försäljning', 'CRM', 'leads', 'offert'],
|
||||
dataTyp: 'sales'
|
||||
},
|
||||
hr: {
|
||||
rum: 'hr',
|
||||
titel: 'HR AI',
|
||||
systemPrompt: `Du är en expert på HR och personalfrågor. Du hjälper användaren med:
|
||||
- Rekrytering och anställningsprocesser
|
||||
- Personalhandbok och policyer
|
||||
- Lönehantering och förmåner
|
||||
- Semesterplanering och frånvaro
|
||||
- Medarbetarsamtal och utveckling
|
||||
|
||||
Du har tillgång till personaldata och HR-statistik.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var empatisk, professionell och diskret.`,
|
||||
kompetenser: ['HR', 'rekrytering', 'lön', 'personal'],
|
||||
dataTyp: 'hr'
|
||||
},
|
||||
crm: {
|
||||
rum: 'crm',
|
||||
titel: 'CRM AI',
|
||||
systemPrompt: `Du är en expert på kundrelationer och CRM. Du hjälper användaren med:
|
||||
- Kundanalys och segmentering
|
||||
- Kundresor och touchpoints
|
||||
- Supportärenden och eskalering
|
||||
- Kundnöjdhet och NPS
|
||||
- Kundhistorik och interaktioner
|
||||
|
||||
Du har tillgång till CRM-data och kundinteraktioner i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.`,
|
||||
kompetenser: ['CRM', 'kundservice', 'support', 'analys'],
|
||||
dataTyp: 'crm'
|
||||
},
|
||||
legal: {
|
||||
rum: 'legal',
|
||||
titel: 'Legal AI',
|
||||
systemPrompt: `Du är en expert på juridik och compliance. Du hjälper användaren med:
|
||||
- Avtalsgranskning och tolkning
|
||||
- GDPR och dataskydd
|
||||
- Företagsjuridik och bolagsstyrning
|
||||
- Immaterialrätt och licenser
|
||||
- Regelverk och efterlevnad
|
||||
|
||||
OBS: Du ersätter inte en advokat. Vid komplexa juridiska frågor, hänvisa alltid till jurist.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var noggrann, försiktig och tydlig med begränsningar.`,
|
||||
kompetenser: ['juridik', 'GDPR', 'avtal', 'compliance'],
|
||||
dataTyp: 'legal'
|
||||
},
|
||||
marketing: {
|
||||
rum: 'marketing',
|
||||
titel: 'Marketing AI',
|
||||
systemPrompt: `Du är en expert på marknadsföring och kommunikation. Du hjälper användaren med:
|
||||
- Kampanjplanering och analys
|
||||
- Sociala medier och content
|
||||
- SEO och digital marknadsföring
|
||||
- Marknadsanalys och konkurrenter
|
||||
- Varumärke och positionering
|
||||
|
||||
Du har tillgång till marknadsdata och kampanjstatistik.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var kreativ, strategisk och datadriven.`,
|
||||
kompetenser: ['marknadsföring', 'SEO', 'sociala medier', 'analys'],
|
||||
dataTyp: 'marketing'
|
||||
},
|
||||
dashboard: {
|
||||
rum: 'dashboard',
|
||||
titel: 'AMOS Assistant',
|
||||
systemPrompt: `Du är AMOS Assistant - en generell AI-assistent för AAMOS-plattformen.
|
||||
Du hjälper användaren med:
|
||||
- Översikt och navigering i systemet
|
||||
- Tekniska frågor om AAMOS-produkter
|
||||
- Integrationer och API:er
|
||||
- Felsökning och support
|
||||
- Allmänna frågor om Landvex och quiXzoom
|
||||
|
||||
Du har bred kunskap om hela plattformen.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var hjälpsam, kunnig och effektiv.`,
|
||||
kompetenser: ['generell', 'support', 'teknik', 'navigering'],
|
||||
dataTyp: 'general'
|
||||
}
|
||||
};
|
||||
|
||||
export function getAgentContext(rum: string): AgentContext {
|
||||
return agentContexts[rum] || agentContexts.dashboard;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @ts-nocheck
|
||||
import { BankAccount } from '@/types/bank';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { RefreshCw, Link2, Link2Off } from 'lucide-react';
|
||||
|
||||
interface BankAccountCardProps {
|
||||
account: BankAccount;
|
||||
onSync: (id: string) => void;
|
||||
onConnect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function BankAccountCard({ account, onSync, onConnect }: BankAccountCardProps) {
|
||||
const bankColors = {
|
||||
revolut: 'bg-blue-50 border-blue-200',
|
||||
nordea: 'bg-red-50 border-red-200',
|
||||
other: 'bg-gray-50 border-gray-200',
|
||||
};
|
||||
|
||||
const bankIcons = {
|
||||
revolut: '🔵',
|
||||
nordea: '🔴',
|
||||
other: '⚪',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-5 ${bankColors[account.bank]} border`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-xl shadow-sm">
|
||||
{bankIcons[account.bank]}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{account.name}</h3>
|
||||
<p className="text-sm text-gray-500">{account.accountNumber}</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">IBAN: {account.iban}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{account.apiConnected ? (
|
||||
<button
|
||||
onClick={() => onSync(account.id)}
|
||||
className="p-2 rounded-lg bg-white hover:bg-gray-50 text-green-600 transition-colors"
|
||||
title="Synka nu"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onConnect(account.id)}
|
||||
className="p-2 rounded-lg bg-white hover:bg-gray-50 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Koppla API"
|
||||
>
|
||||
<Link2Off size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200/60">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-gray-500">Saldo</span>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{account.balance.toLocaleString('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{account.lastSync && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Senast synkad: {new Date(account.lastSync).toLocaleString('sv-SE')}
|
||||
</p>
|
||||
)}
|
||||
{account.apiConnected && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 mt-2">
|
||||
<Link2 size={12} />
|
||||
API kopplad
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useRef } from 'react';
|
||||
import { Upload, FileSpreadsheet, FileText, X } from 'lucide-react';
|
||||
|
||||
interface StatementUploadProps {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
onUpload: (file: File, accountId: string) => void;
|
||||
}
|
||||
|
||||
export function StatementUpload({ accountId, accountName, onUpload }: StatementUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (selectedFile) {
|
||||
onUpload(selectedFile, accountId);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string) => {
|
||||
if (filename.endsWith('.csv')) return <FileSpreadsheet size={24} className="text-green-600" />;
|
||||
if (filename.endsWith('.pdf')) return <FileText size={24} className="text-red-600" />;
|
||||
return <FileText size={24} className="text-gray-600" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">Ladda upp kontoutdrag - {accountName}</h4>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Upload size={32} className="mx-auto text-gray-400 mb-3" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Dra och släpp fil här, eller <span className="text-blue-600">klicka för att välja</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Stödjer CSV, PDF, XLSX
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.pdf,.xlsx"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(selectedFile.name)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedFile(null)}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-400"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Importera kontoutrag
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// @ts-nocheck
|
||||
import { BankTransaction } from '@/types/bank';
|
||||
import { ArrowDownLeft, ArrowUpRight, FileText } from 'lucide-react';
|
||||
|
||||
interface TransactionListProps {
|
||||
transactions: BankTransaction[];
|
||||
onMatch?: (txId: string) => void;
|
||||
}
|
||||
|
||||
export function TransactionList({ transactions, onMatch }: TransactionListProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-white border border-gray-100 hover:border-gray-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
tx.type === 'credit' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
{tx.type === 'credit' ? <ArrowDownLeft size={16} /> : <ArrowUpRight size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{tx.description}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{tx.date}</span>
|
||||
{tx.counterparty && <span>• {tx.counterparty}</span>}
|
||||
{tx.category && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100">{tx.category}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-semibold ${
|
||||
tx.type === 'credit' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{tx.type === 'credit' ? '+' : '-'}
|
||||
{Math.abs(tx.amount).toLocaleString('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: tx.currency,
|
||||
})}
|
||||
</p>
|
||||
{tx.matchedJournalEntryId ? (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1 justify-end mt-1">
|
||||
<FileText size={10} />
|
||||
Bokförd
|
||||
</span>
|
||||
) : onMatch && (
|
||||
<button
|
||||
onClick={() => onMatch(tx.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 mt-1"
|
||||
>
|
||||
Matcha med verifikat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface MarketingStats {
|
||||
active_campaigns: number;
|
||||
scheduled_posts: number;
|
||||
impressions: number;
|
||||
engagement: number;
|
||||
}
|
||||
|
||||
export function MarketingWidget() {
|
||||
const [stats, setStats] = useState<MarketingStats>({
|
||||
active_campaigns: 0,
|
||||
scheduled_posts: 0,
|
||||
impressions: 0,
|
||||
engagement: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data for now
|
||||
setTimeout(() => {
|
||||
setStats({
|
||||
active_campaigns: 3,
|
||||
scheduled_posts: 12,
|
||||
impressions: 45200,
|
||||
engagement: 2340
|
||||
});
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-pink-100 rounded-lg flex items-center justify-center">
|
||||
<Megaphone size={16} className="text-pink-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Marketing</h3>
|
||||
</div>
|
||||
<Link to="/marketing" className="text-sm text-pink-600 hover:text-pink-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="bg-pink-50 rounded-lg p-3">
|
||||
<div className="text-xs text-pink-600 mb-1">Active Campaigns</div>
|
||||
<div className="text-2xl font-bold text-pink-700">{stats.active_campaigns}</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<div className="text-xs text-purple-600 mb-1">Scheduled</div>
|
||||
<div className="text-2xl font-bold text-purple-700">{stats.scheduled_posts}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<Eye size={14} /> Impressions
|
||||
</span>
|
||||
<span className="font-medium">{stats.impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<TrendingUp size={14} /> Engagement
|
||||
</span>
|
||||
<span className="font-medium">{stats.engagement.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FolderKanban, TrendingUp, CheckCircle2, AlertCircle, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface ProjectStats {
|
||||
active: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export function ProjectWidget() {
|
||||
const [stats, setStats] = useState<ProjectStats>({ active: 0, completed: 0, overdue: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/dashboard/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats({
|
||||
active: data.active_issues || 0,
|
||||
completed: 12,
|
||||
overdue: 2
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setStats({ active: 8, completed: 12, overdue: 2 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<FolderKanban size={16} className="text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Projects</h3>
|
||||
</div>
|
||||
<Link to="/projects" className="text-sm text-purple-600 hover:text-purple-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-blue-600" />
|
||||
<span className="text-sm text-blue-700">Active</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-blue-700">{stats.active}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 size={16} className="text-green-600" />
|
||||
<span className="text-sm text-green-700">Completed</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-green-700">{stats.completed}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-red-600" />
|
||||
<span className="text-sm text-red-700">Overdue</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-red-700">{stats.overdue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SLAViolation {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
subject: string;
|
||||
violation_type: string;
|
||||
severity: string;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
export function SLAWidget() {
|
||||
const [violations, setViolations] = useState<SLAViolation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/sla/violations')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setViolations(data.slice(0, 3));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setViolations([
|
||||
{ id: '1', ticket_number: 'SUP-2024-005', subject: 'Database timeout', violation_type: 'sla_exceeded', severity: 'critical', detected_at: new Date().toISOString() },
|
||||
{ id: '2', ticket_number: 'SUP-2024-003', subject: 'Invoice not received', violation_type: 'no_response', severity: 'high', detected_at: new Date().toISOString() },
|
||||
]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Shield size={16} className="text-yellow-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">SLA Status</h3>
|
||||
</div>
|
||||
{violations.length > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||
{violations.length} violations
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{violations.length === 0 ? (
|
||||
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
|
||||
<CheckCircle2 size={20} className="text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700">All SLA met</p>
|
||||
<p className="text-xs text-green-600">No violations detected</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{violations.map(v => (
|
||||
<div key={v.id} className={`p-3 rounded-lg ${
|
||||
v.severity === 'critical' ? 'bg-red-50' :
|
||||
v.severity === 'high' ? 'bg-orange-50' :
|
||||
'bg-yellow-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle size={14} className={
|
||||
v.severity === 'critical' ? 'text-red-600' :
|
||||
v.severity === 'high' ? 'text-orange-600' :
|
||||
'text-yellow-600'
|
||||
} />
|
||||
<span className="text-sm font-medium">{v.ticket_number}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{v.subject}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{v.violation_type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Server, CheckCircle2, AlertCircle, XCircle, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Service {
|
||||
service_name: string;
|
||||
status: string;
|
||||
last_check: string;
|
||||
consecutive_failures: number;
|
||||
}
|
||||
|
||||
export function ServiceHealthWidget() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/services/health')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setServices(data.slice(0, 5));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setServices([
|
||||
{ service_name: 'aamos-ledger', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'aamos-identity', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'api-gateway', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'postgres-primary', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'redis-cache', status: 'down', last_check: new Date().toISOString(), consecutive_failures: 2 },
|
||||
]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const downServices = services.filter(s => s.status === 'down' || s.status === 'error');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Server size={16} className="text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Service Health</h3>
|
||||
</div>
|
||||
{downServices.length > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||
{downServices.length} down
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{services.map(service => (
|
||||
<div key={service.service_name} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
{service.status === 'up' ? (
|
||||
<CheckCircle2 size={14} className="text-green-500" />
|
||||
) : service.status === 'down' ? (
|
||||
<XCircle size={14} className="text-red-500" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm">{service.service_name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
service.status === 'up' ? 'bg-green-100 text-green-700' :
|
||||
service.status === 'down' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{service.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle, Clock, CheckCircle2, Headphones, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface TicketStats {
|
||||
total: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
export function TicketWidget() {
|
||||
const [stats, setStats] = useState<TicketStats>({ total: 0, open: 0, in_progress: 0, resolved: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/tickets/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback mock data
|
||||
setStats({ total: 24, open: 8, in_progress: 5, resolved: 11 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Headphones size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Support Tickets</h3>
|
||||
</div>
|
||||
<Link to="/support" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-orange-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle size={14} className="text-orange-600" />
|
||||
<span className="text-xs text-orange-600 font-medium">Open</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-700">{stats.open}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock size={14} className="text-blue-600" />
|
||||
<span className="text-xs text-blue-600 font-medium">In Progress</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-700">{stats.in_progress}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 size={14} className="text-green-600" />
|
||||
<span className="text-xs text-green-600 font-medium">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">{stats.resolved}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Headphones size={14} className="text-gray-600" />
|
||||
<span className="text-xs text-gray-600 font-medium">Total</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-700">{stats.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Shield,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
Share2,
|
||||
Bot,
|
||||
Briefcase,
|
||||
FileText,
|
||||
Megaphone,
|
||||
HeadphonesIcon,
|
||||
Newspaper,
|
||||
Cpu,
|
||||
Crown,
|
||||
Zap,
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: 'Översikt',
|
||||
items: [
|
||||
{ path: '/', label: 'Briefing', icon: Newspaper },
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/mail', label: 'Mail', icon: Mail },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Produkter',
|
||||
items: [
|
||||
{ path: '/alva', label: 'Alva', icon: Bot },
|
||||
{ path: '/amos', label: 'AMOS', icon: Cpu },
|
||||
{ path: '/quixzoom', label: 'quiXzoom', icon: Globe },
|
||||
{ path: '/landvex', label: 'Landvex', icon: Crown },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Verksamhet',
|
||||
items: [
|
||||
{ path: '/crm', label: 'CRM', icon: Users },
|
||||
{ path: '/sales', label: 'Sales', icon: TrendingUp },
|
||||
{ path: '/marketing', label: 'Marketing', icon: Megaphone },
|
||||
{ path: '/social', label: 'Social', icon: Share2 },
|
||||
{ path: '/finance', label: 'Finance', icon: Wallet },
|
||||
{ path: '/accounting', label: 'Accounting', icon: Briefcase },
|
||||
{ path: '/hr', label: 'HR', icon: Briefcase },
|
||||
{ path: '/legal', label: 'Legal', icon: FileText },
|
||||
{ path: '/compliance', label: 'Compliance', icon: Shield },
|
||||
{ path: '/support', label: 'Support', icon: HeadphonesIcon },
|
||||
{ path: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ path: '/automation', label: 'Automation', icon: Zap },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function MobileNav() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
'Översikt': true,
|
||||
'Produkter': true,
|
||||
'Verksamhet': true,
|
||||
})
|
||||
const location = useLocation()
|
||||
const { logout, isAuthenticated } = useAuthStore()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
const toggleGroup = (label: string) => {
|
||||
setOpenGroups(prev => ({ ...prev, [label]: !prev[label] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top header */}
|
||||
<div className="fixed top-0 left-0 right-0 z-40 bg-bg border-b border-border-subtle px-4 py-3 flex items-center justify-between lg:hidden">
|
||||
<Link to="/" className="text-lg font-bold text-text-primary tracking-wide">
|
||||
BOC
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-2 rounded-lg bg-surface text-text-primary active:bg-surface-hover"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Full screen overlay */}
|
||||
{menuOpen && (
|
||||
<div className="fixed inset-0 z-[100] bg-bg lg:hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle bg-bg shrink-0">
|
||||
<span className="text-lg font-bold text-text-primary">Meny</span>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="p-2 rounded-lg bg-surface text-text-primary active:bg-surface-hover border border-border/40"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav groups */}
|
||||
<div className="p-4 space-y-2 overflow-y-auto flex-1">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
className="flex items-center justify-between w-full px-4 py-2.5 rounded-xl text-sm font-semibold uppercase tracking-wider text-text-tertiary hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{group.label}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn('transition-transform', !openGroups[group.label] && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{openGroups[group.label] && (
|
||||
<div className="ml-2 space-y-0.5 mt-1">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium transition-colors',
|
||||
isActive(item.path)
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-text-secondary hover:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={() => { logout(); setMenuOpen(false); window.location.href = '/login' }}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium text-danger hover:bg-danger-light transition-colors w-full mt-4"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
Logga ut
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { X, Send, Paperclip, Sparkles, Loader2 } from 'lucide-react'
|
||||
|
||||
interface ComposeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
replyTo?: {
|
||||
uid: number
|
||||
subject: string
|
||||
from: string
|
||||
body: string
|
||||
}
|
||||
onSent?: () => void
|
||||
}
|
||||
|
||||
export function ComposeModal({ isOpen, onClose, replyTo, onSent }: ComposeModalProps) {
|
||||
const [to, setTo] = useState(replyTo ? extractEmail(replyTo.from) : '')
|
||||
const [subject, setSubject] = useState(replyTo ? `Re: ${replyTo.subject.replace(/^Re: /i, '')}` : '')
|
||||
const [body, setBody] = useState(replyTo ? `\n\n---\n${replyTo.body.substring(0, 500)}` : '')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<File[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
function extractEmail(from: string): string {
|
||||
const match = from.match(/<([^>]+)>/)
|
||||
return match ? match[1] : from
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!to || !subject || !body) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const token = localStorage.getItem('amos_token')
|
||||
const res = await fetch('/api/v1/mail/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: [to],
|
||||
subject,
|
||||
body,
|
||||
reply_to: replyTo?.from,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
setTo('')
|
||||
setSubject('')
|
||||
setBody('')
|
||||
setAttachments([])
|
||||
onSent?.()
|
||||
onClose()
|
||||
} else {
|
||||
alert(data.error || 'Failed to send')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to send email')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAIAssist() {
|
||||
if (!body.trim()) return
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const token = localStorage.getItem('amos_token')
|
||||
const res = await fetch('/api/v1/mail/ai-assist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
context: body,
|
||||
tone: 'professional',
|
||||
language: 'sv',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.ok && data.improved) {
|
||||
setBody(data.improved)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI assist failed:', err)
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files) {
|
||||
setAttachments(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{replyTo ? 'Svara' : 'Nytt meddelande'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-bg text-text-secondary">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Till</label>
|
||||
<input
|
||||
type="email"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Ämne</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="Ämne"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Meddelande</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
|
||||
placeholder="Skriv ditt meddelande..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-text-secondary">Bilagor</p>
|
||||
{attachments.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-2 bg-bg rounded-lg">
|
||||
<Paperclip size={16} className="text-text-secondary" />
|
||||
<span className="text-sm text-text-primary">{file.name}</span>
|
||||
<span className="text-xs text-text-secondary">({(file.size / 1024).toFixed(0)} KB)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Paperclip size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Bifoga
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={aiLoading ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
onClick={handleAIAssist}
|
||||
disabled={aiLoading || !body.trim()}
|
||||
>
|
||||
AI-hjälp
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon={sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
onClick={handleSend}
|
||||
disabled={sending || !to || !subject || !body}
|
||||
>
|
||||
{sending ? 'Skickar...' : 'Skicka'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const saved = localStorage.getItem('amos-dark-mode')
|
||||
const isDark = saved ? saved === 'true' : prefersDark
|
||||
setDarkMode(isDark)
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const newMode = !darkMode
|
||||
setDarkMode(newMode)
|
||||
localStorage.setItem('amos-dark-mode', String(newMode))
|
||||
|
||||
if (newMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors',
|
||||
darkMode
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-bg text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface GestureNavProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function GestureNav({ children }: GestureNavProps) {
|
||||
const navigate = useNavigate()
|
||||
const lastTap = useRef<number>(0)
|
||||
const tapCount = useRef<number>(0)
|
||||
const tapTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleTap = useCallback((_e: React.TouchEvent) => {
|
||||
const now = Date.now()
|
||||
const timeDiff = now - lastTap.current
|
||||
|
||||
if (timeDiff < 300) {
|
||||
// Double tap detected
|
||||
tapCount.current += 1
|
||||
|
||||
if (tapCount.current === 2) {
|
||||
// Triple tap - go to dashboard
|
||||
Haptics.medium()
|
||||
navigate('/dashboard')
|
||||
tapCount.current = 0
|
||||
}
|
||||
} else {
|
||||
tapCount.current = 1
|
||||
}
|
||||
|
||||
lastTap.current = now
|
||||
|
||||
// Reset tap count after delay
|
||||
if (tapTimer.current) {
|
||||
clearTimeout(tapTimer.current)
|
||||
}
|
||||
tapTimer.current = setTimeout(() => {
|
||||
tapCount.current = 0
|
||||
}, 500)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div onTouchEnd={handleTap}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function MobileCard({ children, className, onClick }: MobileCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
onClick && 'active:scale-[0.98] transition-transform cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardRowProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardRow({ label, value, className }: MobileCardRowProps) {
|
||||
return (
|
||||
<div className={cn('flex justify-between items-center py-2', className)}>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardBadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardBadge({ children, variant = 'default', className }: MobileCardBadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-bg text-text-secondary',
|
||||
success: 'bg-success-light text-success',
|
||||
warning: 'bg-warning-light text-warning',
|
||||
danger: 'bg-danger-light text-danger',
|
||||
info: 'bg-primary-light text-primary',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('text-xs font-medium px-2.5 py-1 rounded-full', variants[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface MobileChartProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: number
|
||||
changeLabel?: string
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger'
|
||||
sparklineData?: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileChart({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changeLabel,
|
||||
color = 'primary',
|
||||
sparklineData,
|
||||
className,
|
||||
}: MobileChartProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const colors = {
|
||||
primary: 'text-primary bg-primary-light',
|
||||
success: 'text-success bg-success-light',
|
||||
warning: 'text-warning bg-warning-light',
|
||||
danger: 'text-danger bg-danger-light',
|
||||
}
|
||||
|
||||
const sparklineColor = {
|
||||
primary: '#2563EB',
|
||||
success: '#16A34A',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
}
|
||||
|
||||
// Simple SVG sparkline
|
||||
const renderSparkline = () => {
|
||||
if (!sparklineData || sparklineData.length < 2) return null
|
||||
|
||||
const width = 120
|
||||
const height = 40
|
||||
const max = Math.max(...sparklineData)
|
||||
const min = Math.min(...sparklineData)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = sparklineData.map((v, i) => {
|
||||
const x = (i / (sparklineData.length - 1)) * width
|
||||
const y = height - ((v - min) / range) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="mt-2">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={sparklineColor[color]}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary uppercase tracking-wider">{title}</p>
|
||||
<p className="text-2xl font-semibold text-text-primary mt-1">{value}</p>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn('text-xs font-medium', change >= 0 ? 'text-success' : 'text-danger')}>
|
||||
{change >= 0 ? '+' : ''}{change}%
|
||||
</span>
|
||||
{changeLabel && (
|
||||
<span className="text-xs text-text-secondary">{changeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||
colors[color]
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && sparklineData && (
|
||||
<div className="mt-3 pt-3 border-t border-border/40">
|
||||
{renderSparkline()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface PullToRefreshProps {
|
||||
onRefresh: () => Promise<void>
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const touchStartY = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const maxPullDistance = 100
|
||||
const refreshThreshold = 80
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// Only allow pull-to-refresh when at top of scroll
|
||||
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||||
touchStartY.current = e.targetTouches[0].clientY
|
||||
setPulling(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!pulling) return
|
||||
|
||||
const currentY = e.targetTouches[0].clientY
|
||||
const diff = currentY - touchStartY.current
|
||||
|
||||
if (diff > 0) {
|
||||
// Resistance increases as user pulls further
|
||||
const resistance = 1 + (diff / maxPullDistance) * 0.5
|
||||
const newDistance = Math.min(diff / resistance, maxPullDistance)
|
||||
setPullDistance(newDistance)
|
||||
}
|
||||
}, [pulling])
|
||||
|
||||
const onTouchEnd = useCallback(async () => {
|
||||
if (!pulling) return
|
||||
|
||||
if (pullDistance >= refreshThreshold && !refreshing) {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
setPulling(false)
|
||||
setPullDistance(0)
|
||||
}, [pulling, pullDistance, refreshing, onRefresh])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative overflow-y-auto', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Pull indicator */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 flex items-center justify-center transition-transform"
|
||||
style={{
|
||||
transform: `translateY(${pullDistance - 60}px)`,
|
||||
opacity: pulling ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw
|
||||
size={24}
|
||||
className={cn(
|
||||
'text-primary transition-transform',
|
||||
refreshing && 'animate-spin',
|
||||
!refreshing && pullDistance >= refreshThreshold && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{refreshing ? 'Refreshing...' : pullDistance >= refreshThreshold ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content with offset when pulling */}
|
||||
<div
|
||||
style={{
|
||||
transform: pulling ? `translateY(${pullDistance}px)` : 'translateY(0)',
|
||||
transition: pulling ? 'none' : 'transform 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { MobileCard, MobileCardRow } from './MobileCard'
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (item: T) => React.ReactNode
|
||||
mobile?: boolean // show on mobile?
|
||||
}
|
||||
|
||||
interface ResponsiveTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
onRowClick?: (item: T) => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
export function ResponsiveTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
title,
|
||||
subtitle,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data',
|
||||
}: ResponsiveTableProps<T>) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const newSet = new Set(expandedRows)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
setExpandedRows(newSet)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-secondary">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="py-3 px-4 text-xs font-medium text-text-secondary uppercase tracking-wider text-left"
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors hover:bg-bg/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="py-3.5 px-4 text-sm text-text-primary">
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{title && <h3 className="text-lg font-semibold mb-2">{title}</h3>}
|
||||
{subtitle && <p className="text-sm text-text-secondary mb-4">{subtitle}</p>}
|
||||
{data.map((item) => {
|
||||
const id = keyExtractor(item)
|
||||
const isExpanded = expandedRows.has(id)
|
||||
const mobileColumns = columns.filter((c) => c.mobile !== false)
|
||||
const primaryCol = mobileColumns[0]
|
||||
const secondaryCols = mobileColumns.slice(1)
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={id}
|
||||
onClick={() => {
|
||||
if (secondaryCols.length > 2) {
|
||||
toggleRow(id)
|
||||
} else {
|
||||
onRowClick?.(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">
|
||||
{primaryCol?.render(item)}
|
||||
</div>
|
||||
{secondaryCols.length <= 2 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{secondaryCols.slice(0, 2).map((col) => (
|
||||
<span key={col.key} className="text-xs text-text-secondary">
|
||||
{col.header}: {col.render(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{secondaryCols.length > 2 && (
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
'text-text-secondary transition-transform',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && secondaryCols.length > 2 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50 space-y-1">
|
||||
{secondaryCols.map((col) => (
|
||||
<MobileCardRow
|
||||
key={col.key}
|
||||
label={col.header}
|
||||
value={col.render(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MobileCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface SwipeContainerProps {
|
||||
children: React.ReactNode
|
||||
onSwipeLeft?: () => void
|
||||
onSwipeRight?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SwipeContainer({ children, onSwipeLeft, onSwipeRight, className }: SwipeContainerProps) {
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
const touchEnd = useRef<{ x: number; y: number } | null>(null)
|
||||
const [swiping, setSwiping] = useState(false)
|
||||
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = null
|
||||
touchStart.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
setSwiping(true)
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
}, [])
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
setSwiping(false)
|
||||
if (!touchStart.current || !touchEnd.current) return
|
||||
|
||||
const distanceX = touchStart.current.x - touchEnd.current.x
|
||||
const distanceY = touchStart.current.y - touchEnd.current.y
|
||||
const isHorizontalSwipe = Math.abs(distanceX) > Math.abs(distanceY)
|
||||
|
||||
if (isHorizontalSwipe && Math.abs(distanceX) > minSwipeDistance) {
|
||||
Haptics.swipe()
|
||||
if (distanceX > 0) {
|
||||
onSwipeLeft?.()
|
||||
} else {
|
||||
onSwipeRight?.()
|
||||
}
|
||||
}
|
||||
|
||||
touchStart.current = null
|
||||
touchEnd.current = null
|
||||
}, [onSwipeLeft, onSwipeRight])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('touch-pan-y', swiping && 'select-none', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// @ts-nocheck
|
||||
import { BankAccount, BankTransaction, BankStatement } from '@/types/bank';
|
||||
|
||||
export const bankAccounts: BankAccount[] = [
|
||||
{
|
||||
id: 'revolut-main',
|
||||
name: 'Revolut Business',
|
||||
bank: 'revolut',
|
||||
accountNumber: '1234 5678 9012 3456',
|
||||
iban: 'GB29 NWBK 6016 1331 9268 19',
|
||||
currency: 'SEK',
|
||||
balance: 125430.50,
|
||||
status: 'active',
|
||||
lastSync: '2026-08-08T22:00:00Z',
|
||||
apiConnected: false,
|
||||
},
|
||||
{
|
||||
id: 'nordea-checking',
|
||||
name: 'Nordea Företagskonto',
|
||||
bank: 'nordea',
|
||||
accountNumber: '3456 7890 1234 5678',
|
||||
iban: 'SE45 5000 0000 0583 9825 7466',
|
||||
currency: 'SEK',
|
||||
balance: 89200.00,
|
||||
status: 'active',
|
||||
lastSync: '2026-08-08T22:00:00Z',
|
||||
apiConnected: false,
|
||||
},
|
||||
{
|
||||
id: 'nordea-savings',
|
||||
name: 'Nordea Sparkonto',
|
||||
bank: 'nordea',
|
||||
accountNumber: '9876 5432 1098 7654',
|
||||
currency: 'SEK',
|
||||
balance: 250000.00,
|
||||
status: 'active',
|
||||
apiConnected: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const recentTransactions: BankTransaction[] = [
|
||||
{
|
||||
id: 'tx-001',
|
||||
accountId: 'revolut-main',
|
||||
date: '2026-08-08',
|
||||
description: 'Atlas Capture AB - Månadsavgift',
|
||||
amount: -299.00,
|
||||
currency: 'SEK',
|
||||
type: 'debit',
|
||||
category: 'Programvara',
|
||||
counterparty: 'Atlas Capture AB',
|
||||
importedFrom: 'manual',
|
||||
},
|
||||
{
|
||||
id: 'tx-002',
|
||||
accountId: 'revolut-main',
|
||||
date: '2026-08-07',
|
||||
description: 'Kundbetalning - Faktura #1001',
|
||||
amount: 15000.00,
|
||||
currency: 'SEK',
|
||||
type: 'credit',
|
||||
category: 'Försäljning',
|
||||
counterparty: 'Kund AB',
|
||||
importedFrom: 'manual',
|
||||
},
|
||||
{
|
||||
id: 'tx-003',
|
||||
accountId: 'nordea-checking',
|
||||
date: '2026-08-06',
|
||||
description: 'Lön - Johan Berglund',
|
||||
amount: -45000.00,
|
||||
currency: 'SEK',
|
||||
type: 'debit',
|
||||
category: 'Lönekostnad',
|
||||
counterparty: 'Johan Berglund',
|
||||
importedFrom: 'manual',
|
||||
},
|
||||
{
|
||||
id: 'tx-004',
|
||||
accountId: 'nordea-checking',
|
||||
date: '2026-08-05',
|
||||
description: 'Hyra kontor - Wavult Group',
|
||||
amount: -8500.00,
|
||||
currency: 'SEK',
|
||||
type: 'debit',
|
||||
category: 'Lokalhyra',
|
||||
counterparty: 'Wavult Group',
|
||||
importedFrom: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
export const statements: BankStatement[] = [
|
||||
{
|
||||
id: 'stmt-001',
|
||||
accountId: 'revolut-main',
|
||||
fileName: 'revolut_2026-07.csv',
|
||||
fileType: 'csv',
|
||||
uploadDate: '2026-08-01T10:00:00Z',
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
transactionCount: 45,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'stmt-002',
|
||||
accountId: 'nordea-checking',
|
||||
fileName: 'nordea_juli_2026.pdf',
|
||||
fileType: 'pdf',
|
||||
uploadDate: '2026-08-02T14:30:00Z',
|
||||
periodStart: '2026-07-01',
|
||||
periodEnd: '2026-07-31',
|
||||
transactionCount: 32,
|
||||
status: 'completed',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
const API_BASE = 'http://localhost:3457';
|
||||
|
||||
export async function fetchTickets(params?: { status?: string; search?: string }) {
|
||||
const query = new URLSearchParams(params as Record<string, string>);
|
||||
const res = await fetch(`${API_BASE}/api/tickets?${query}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTicketStats() {
|
||||
const res = await fetch(`${API_BASE}/api/tickets/stats`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createTicket(data: any) {
|
||||
const res = await fetch(`${API_BASE}/api/tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchProjects() {
|
||||
const res = await fetch(`${API_BASE}/api/projects`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchIssues(params?: { project_id?: string }) {
|
||||
const query = new URLSearchParams(params as Record<string, string>);
|
||||
const res = await fetch(`${API_BASE}/api/issues?${query}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createIssue(data: any) {
|
||||
const res = await fetch(`${API_BASE}/api/issues`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDashboardStats() {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard/stats`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServiceHealth() {
|
||||
const res = await fetch(`${API_BASE}/api/services/health`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSLAViolations() {
|
||||
const res = await fetch(`${API_BASE}/api/sla/violations`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Haptic feedback utility for mobile devices
|
||||
*/
|
||||
|
||||
export const Haptics = {
|
||||
/**
|
||||
* Light impact feedback (selection, tap)
|
||||
*/
|
||||
light: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate(10)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Medium impact feedback (swipe, toggle)
|
||||
*/
|
||||
medium: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate(20)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Heavy impact feedback (error, success)
|
||||
*/
|
||||
heavy: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate([30, 50, 30])
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Success feedback pattern
|
||||
*/
|
||||
success: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate([10, 30, 10])
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Error feedback pattern
|
||||
*/
|
||||
error: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate([50, 30, 50])
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Swipe feedback
|
||||
*/
|
||||
swipe: () => {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate(15)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { SupportTicket, ProjectIssue, Project } from './types';
|
||||
|
||||
export const mockTickets: SupportTicket[] = [
|
||||
{
|
||||
id: '1',
|
||||
number: 'SUP-2024-001',
|
||||
subject: 'Login issues after password reset',
|
||||
description: 'Users unable to login after resetting password. Getting 401 error.',
|
||||
customer: { id: 'c1', name: 'Acme Corp', email: 'support@acme.com', company: 'Acme Corp' },
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
category: 'technical',
|
||||
assigned_to: { id: 'a1', name: 'Johan Berg' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
sla_deadline: new Date(Date.now() + 1000 * 60 * 60 * 4).toISOString(),
|
||||
tags: ['login', 'auth', 'urgent'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
related_infrastructure: ['aamos-identity', 'auth-service'],
|
||||
internal_notes: [
|
||||
{ id: 'n1', author: 'Johan Berg', content: 'Checking auth logs...', created_at: new Date().toISOString() }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
number: 'SUP-2024-002',
|
||||
subject: 'API rate limit questions',
|
||||
description: 'Customer asking about increasing API rate limits for enterprise plan.',
|
||||
customer: { id: 'c2', name: 'Nordic Solutions', email: 'api@nordic.se', company: 'Nordic Solutions' },
|
||||
priority: 'medium',
|
||||
status: 'in_progress',
|
||||
category: 'technical',
|
||||
assigned_to: { id: 'a1', name: 'Johan Berg' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
tags: ['api', 'rate-limit', 'enterprise'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
related_infrastructure: ['api-gateway', 'rate-limiter'],
|
||||
internal_notes: []
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
number: 'SUP-2024-003',
|
||||
subject: 'Invoice not received',
|
||||
description: 'Customer reports not receiving invoice for June 2024.',
|
||||
customer: { id: 'c3', name: 'TechStart AB', email: 'billing@techstart.se' },
|
||||
priority: 'medium',
|
||||
status: 'open',
|
||||
category: 'billing',
|
||||
assigned_to: { id: 'a2', name: 'Erik Svensson' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
|
||||
tags: ['invoice', 'billing'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
internal_notes: []
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
number: 'SUP-2024-004',
|
||||
subject: 'Feature request: Webhook retries',
|
||||
description: 'Request for automatic webhook retry with exponential backoff.',
|
||||
customer: { id: 'c4', name: 'DataFlow Inc', email: 'dev@dataflow.io' },
|
||||
priority: 'low',
|
||||
status: 'pending',
|
||||
category: 'feature_request',
|
||||
assigned_to: { id: 'a3', name: 'Anna Lind' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
tags: ['webhook', 'feature-request'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
internal_notes: []
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
number: 'SUP-2024-005',
|
||||
subject: 'Database connection timeout',
|
||||
description: 'Intermittent connection timeouts to PostgreSQL database.',
|
||||
customer: { id: 'c5', name: 'Internal', email: 'ops@amos.ai' },
|
||||
priority: 'urgent',
|
||||
status: 'in_progress',
|
||||
category: 'infrastructure',
|
||||
assigned_to: { id: 'a1', name: 'Johan Berg' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
||||
sla_deadline: new Date(Date.now() + 1000 * 60 * 60).toISOString(),
|
||||
tags: ['database', 'postgres', 'timeout', 'critical'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
related_infrastructure: ['postgres-primary', 'connection-pooler'],
|
||||
internal_notes: [
|
||||
{ id: 'n2', author: 'Johan Berg', content: 'Checking connection pool settings...', created_at: new Date().toISOString() }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
number: 'SUP-2024-006',
|
||||
subject: 'SSL certificate expiry warning',
|
||||
description: 'SSL certificate for api.aamos.ai expires in 7 days.',
|
||||
customer: { id: 'c5', name: 'Internal', email: 'ops@amos.ai' },
|
||||
priority: 'high',
|
||||
status: 'resolved',
|
||||
category: 'infrastructure',
|
||||
assigned_to: { id: 'a1', name: 'Johan Berg' },
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
resolved_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
tags: ['ssl', 'certificate', 'security'],
|
||||
comments: [],
|
||||
attachments: [],
|
||||
related_infrastructure: ['api-gateway', 'ssl-terminator'],
|
||||
internal_notes: [
|
||||
{ id: 'n3', author: 'Johan Berg', content: 'Renewed certificate via ACM', created_at: new Date().toISOString() }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
key: 'INFRA',
|
||||
name: 'Infrastructure',
|
||||
description: 'Infrastructure and DevOps tasks',
|
||||
color: '#DC2626',
|
||||
category: 'infrastructure',
|
||||
lead: 'Johan Berg',
|
||||
members: ['Johan Berg', 'Erik Svensson'],
|
||||
boards: [],
|
||||
sprints: []
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
key: 'DEV',
|
||||
name: 'Development',
|
||||
description: 'Software development tasks',
|
||||
color: '#2563EB',
|
||||
category: 'development',
|
||||
lead: 'Erik Svensson',
|
||||
members: ['Erik Svensson', 'Anna Lind'],
|
||||
boards: [],
|
||||
sprints: []
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
key: 'OPS',
|
||||
name: 'Operations',
|
||||
description: 'Daily operations and maintenance',
|
||||
color: '#16A34A',
|
||||
category: 'operations',
|
||||
lead: 'Johan Berg',
|
||||
members: ['Johan Berg'],
|
||||
boards: [],
|
||||
sprints: []
|
||||
}
|
||||
];
|
||||
|
||||
export const mockIssues: ProjectIssue[] = [
|
||||
{
|
||||
id: 'i1',
|
||||
key: 'INFRA-2024-001',
|
||||
summary: 'Migrate database to RDS Multi-AZ',
|
||||
description: 'Set up PostgreSQL Multi-AZ deployment for high availability.',
|
||||
issue_type: 'infrastructure',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignee: { id: 'a1', name: 'Johan Berg' },
|
||||
reporter: { id: 'a2', name: 'Erik Svensson' },
|
||||
project: { id: 'p1', key: 'INFRA', name: 'Infrastructure' },
|
||||
labels: ['database', 'high-availability'],
|
||||
components: ['postgres', 'rds'],
|
||||
affected_services: ['aamos-ledger', 'aamos-identity'],
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
story_points: 8,
|
||||
time_estimate: 480,
|
||||
time_spent: 240,
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
linked_issues: [],
|
||||
infrastructure_details: {
|
||||
affected_servers: ['db-primary', 'db-replica'],
|
||||
affected_services: ['aamos-ledger', 'aamos-identity'],
|
||||
severity: 'major',
|
||||
impact: 'Database downtime during migration window'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'i2',
|
||||
key: 'INFRA-2024-002',
|
||||
summary: 'Set up monitoring for all microservices',
|
||||
description: 'Implement Prometheus + Grafana monitoring stack.',
|
||||
issue_type: 'infrastructure',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
assignee: { id: 'a1', name: 'Johan Berg' },
|
||||
reporter: { id: 'a1', name: 'Johan Berg' },
|
||||
project: { id: 'p1', key: 'INFRA', name: 'Infrastructure' },
|
||||
labels: ['monitoring', 'observability'],
|
||||
components: ['prometheus', 'grafana'],
|
||||
affected_services: ['all-services'],
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
story_points: 5,
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
linked_issues: []
|
||||
},
|
||||
{
|
||||
id: 'i3',
|
||||
key: 'DEV-2024-045',
|
||||
summary: 'Implement webhook retry logic',
|
||||
description: 'Add exponential backoff retry for failed webhooks.',
|
||||
issue_type: 'story',
|
||||
status: 'backlog',
|
||||
priority: 'medium',
|
||||
reporter: { id: 'a3', name: 'Anna Lind' },
|
||||
project: { id: 'p2', key: 'DEV', name: 'Development' },
|
||||
labels: ['webhook', 'reliability'],
|
||||
components: ['api'],
|
||||
affected_services: ['webhook-service'],
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
story_points: 3,
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
linked_issues: []
|
||||
},
|
||||
{
|
||||
id: 'i4',
|
||||
key: 'INFRA-2024-003',
|
||||
summary: 'SSL certificate automation',
|
||||
description: 'Automate SSL certificate renewal with Let\'s Encrypt.',
|
||||
issue_type: 'infrastructure',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
assignee: { id: 'a1', name: 'Johan Berg' },
|
||||
reporter: { id: 'a2', name: 'Erik Svensson' },
|
||||
project: { id: 'p1', key: 'INFRA', name: 'Infrastructure' },
|
||||
labels: ['ssl', 'automation', 'security'],
|
||||
components: ['cert-manager'],
|
||||
affected_services: ['all-services'],
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 10).toISOString(),
|
||||
updated_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
resolved_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
story_points: 5,
|
||||
time_estimate: 240,
|
||||
time_spent: 180,
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
linked_issues: []
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,163 @@
|
||||
// ZENDESK-STYLE SUPPORT TICKETS
|
||||
export interface SupportTicket {
|
||||
id: string;
|
||||
number: string; // SUP-2024-001
|
||||
subject: string;
|
||||
description: string;
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
company?: string;
|
||||
};
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'open' | 'in_progress' | 'pending' | 'resolved' | 'closed';
|
||||
category: 'technical' | 'billing' | 'account' | 'feature_request' | 'bug' | 'infrastructure';
|
||||
assigned_to?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
resolved_at?: string;
|
||||
sla_deadline?: string;
|
||||
tags: string[];
|
||||
comments: TicketComment[];
|
||||
attachments: Attachment[];
|
||||
related_infrastructure?: string[]; // Server, service, etc.
|
||||
internal_notes: InternalNote[];
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
id: string;
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
is_agent: boolean;
|
||||
};
|
||||
content: string;
|
||||
created_at: string;
|
||||
is_internal: boolean;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
export interface InternalNote {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
// JIRA-STYLE PROJECT ISSUES
|
||||
export interface ProjectIssue {
|
||||
id: string;
|
||||
key: string; // INFRA-2024-001, DEV-2024-045
|
||||
summary: string;
|
||||
description: string;
|
||||
issue_type: 'epic' | 'story' | 'task' | 'bug' | 'subtask' | 'infrastructure';
|
||||
status: 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'blocked';
|
||||
priority: 'lowest' | 'low' | 'medium' | 'high' | 'highest';
|
||||
assignee?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
reporter: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
};
|
||||
sprint?: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'future' | 'closed';
|
||||
};
|
||||
labels: string[];
|
||||
components: string[]; // Infrastructure components
|
||||
affected_services: string[]; // aamos-ledger, aamos-identity, etc.
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
resolved_at?: string;
|
||||
story_points?: number;
|
||||
time_estimate?: number; // minutes
|
||||
time_spent?: number;
|
||||
subtasks: ProjectIssue[];
|
||||
comments: IssueComment[];
|
||||
linked_issues: LinkedIssue[];
|
||||
infrastructure_details?: InfrastructureDetails;
|
||||
}
|
||||
|
||||
export interface IssueComment {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LinkedIssue {
|
||||
id: string;
|
||||
key: string;
|
||||
relation: 'blocks' | 'is_blocked_by' | 'relates_to' | 'duplicates';
|
||||
}
|
||||
|
||||
export interface InfrastructureDetails {
|
||||
affected_servers: string[];
|
||||
affected_services: string[];
|
||||
severity: 'critical' | 'major' | 'minor' | 'info';
|
||||
impact: string;
|
||||
workaround?: string;
|
||||
root_cause?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
// PROJECT / BOARD
|
||||
export interface Project {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color: string;
|
||||
category: 'infrastructure' | 'development' | 'operations' | 'business';
|
||||
lead: string;
|
||||
members: string[];
|
||||
boards: Board[];
|
||||
sprints: Sprint[];
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: BoardColumn[];
|
||||
}
|
||||
|
||||
export interface BoardColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
issues: ProjectIssue[];
|
||||
wip_limit?: number;
|
||||
}
|
||||
|
||||
export interface Sprint {
|
||||
id: string;
|
||||
name: string;
|
||||
goal?: string;
|
||||
status: 'active' | 'future' | 'closed';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
issues: ProjectIssue[];
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Battery,
|
||||
Plus,
|
||||
Copy,
|
||||
Edit,
|
||||
FileText,
|
||||
Image,
|
||||
Video,
|
||||
Layout
|
||||
} from 'lucide-react';
|
||||
import { MarketingBattery as BatteryType } from '../types';
|
||||
import { mockBattery } from '../data/mockPlan';
|
||||
|
||||
const typeIcons = {
|
||||
campaign: Layout,
|
||||
template: FileText,
|
||||
cta: Plus,
|
||||
image_format: Image,
|
||||
video_format: Video,
|
||||
text_template: FileText
|
||||
};
|
||||
|
||||
export function MarketingBattery() {
|
||||
const [battery] = useState<BatteryType[]>(mockBattery);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? battery
|
||||
: battery.filter(b => b.type === filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Marketing Battery</h1>
|
||||
<p className="text-gray-500">Reusable campaigns, templates, and content formats</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<Plus size={18} />
|
||||
Add Asset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'campaign', 'template', 'cta', 'image_format', 'video_format'].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFilter(type)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors capitalize ${
|
||||
filter === type ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{type.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Battery Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map(item => {
|
||||
const Icon = typeIcons[item.type] || Battery;
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white border rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<Icon size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{item.name}</h3>
|
||||
<span className="text-xs text-gray-500 capitalize">{item.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded">
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded">
|
||||
<Edit size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||||
<pre className="text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify(item.content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{item.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Used {item.usageCount} times
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Plus,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Pause,
|
||||
Play,
|
||||
Instagram,
|
||||
Facebook,
|
||||
Linkedin,
|
||||
Mail,
|
||||
Globe,
|
||||
MapPin,
|
||||
Youtube,
|
||||
Video
|
||||
} from 'lucide-react';
|
||||
import { Activity, ActivityStatus, Channel } from '../types';
|
||||
import { mockActivities } from '../data/mockPlan';
|
||||
|
||||
const channelIcons: Record<Channel, any> = {
|
||||
instagram: Instagram,
|
||||
facebook: Facebook,
|
||||
linkedin: Linkedin,
|
||||
newsletter: Mail,
|
||||
website: Globe,
|
||||
google_business: MapPin,
|
||||
youtube: Youtube,
|
||||
tiktok: Video,
|
||||
email: Mail
|
||||
};
|
||||
|
||||
const statusColors: Record<ActivityStatus, string> = {
|
||||
planned: 'bg-gray-100 text-gray-600',
|
||||
brief_created: 'bg-blue-50 text-blue-600',
|
||||
in_production: 'bg-yellow-50 text-yellow-600',
|
||||
generated: 'bg-purple-50 text-purple-600',
|
||||
review_required: 'bg-orange-50 text-orange-600',
|
||||
approved: 'bg-green-50 text-green-600',
|
||||
scheduled: 'bg-cyan-50 text-cyan-600',
|
||||
publishing: 'bg-indigo-50 text-indigo-600',
|
||||
published: 'bg-emerald-50 text-emerald-600',
|
||||
verified: 'bg-teal-50 text-teal-600',
|
||||
failed: 'bg-red-50 text-red-600',
|
||||
paused: 'bg-amber-50 text-amber-600',
|
||||
cancelled: 'bg-slate-50 text-slate-600'
|
||||
};
|
||||
|
||||
const statusIcons: Record<ActivityStatus, any> = {
|
||||
planned: Clock,
|
||||
brief_created: Clock,
|
||||
in_production: Clock,
|
||||
generated: CheckCircle2,
|
||||
review_required: AlertCircle,
|
||||
approved: CheckCircle2,
|
||||
scheduled: Clock,
|
||||
publishing: Play,
|
||||
published: CheckCircle2,
|
||||
verified: CheckCircle2,
|
||||
failed: AlertCircle,
|
||||
paused: Pause,
|
||||
cancelled: AlertCircle
|
||||
};
|
||||
|
||||
type ViewType = 'year' | 'quarter' | 'month' | 'week' | 'day';
|
||||
|
||||
export function MarketingCalendar() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState<ViewType>('month');
|
||||
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState<Channel | 'all'>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<ActivityStatus | 'all'>('all');
|
||||
|
||||
const activities = mockActivities;
|
||||
|
||||
const filteredActivities = activities.filter(a => {
|
||||
if (filterChannel !== 'all' && a.channel !== filterChannel) return false;
|
||||
if (filterStatus !== 'all' && a.status !== filterStatus) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentDate);
|
||||
switch (view) {
|
||||
case 'year':
|
||||
newDate.setFullYear(newDate.getFullYear() + (direction === 'next' ? 1 : -1));
|
||||
break;
|
||||
case 'quarter':
|
||||
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 3 : -3));
|
||||
break;
|
||||
case 'month':
|
||||
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
||||
break;
|
||||
case 'week':
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
||||
break;
|
||||
case 'day':
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
break;
|
||||
}
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const getViewTitle = () => {
|
||||
switch (view) {
|
||||
case 'year':
|
||||
return currentDate.getFullYear().toString();
|
||||
case 'quarter':
|
||||
const q = Math.floor(currentDate.getMonth() / 3) + 1;
|
||||
return `Q${q} ${currentDate.getFullYear()}`;
|
||||
case 'month':
|
||||
return currentDate.toLocaleDateString('sv-SE', { month: 'long', year: 'numeric' });
|
||||
case 'week':
|
||||
const weekStart = new Date(currentDate);
|
||||
weekStart.setDate(currentDate.getDate() - currentDate.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
return `${weekStart.toLocaleDateString('sv-SE')} - ${weekEnd.toLocaleDateString('sv-SE')}`;
|
||||
case 'day':
|
||||
return currentDate.toLocaleDateString('sv-SE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Marketing Calendar</h1>
|
||||
<div className="flex items-center gap-2 bg-gray-100 rounded-lg p-1">
|
||||
{(['year', 'quarter', 'month', 'week', 'day'] as ViewType[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors capitalize ${
|
||||
view === v ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="text-lg font-semibold min-w-[200px] text-center">
|
||||
{getViewTitle()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<Plus size={18} />
|
||||
New Activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 p-4 border-b bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">Filters:</span>
|
||||
</div>
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value as Channel | 'all')}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="all">All Channels</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
<option value="linkedin">LinkedIn</option>
|
||||
<option value="newsletter">Newsletter</option>
|
||||
<option value="website">Website</option>
|
||||
<option value="google_business">Google Business</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as ActivityStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="planned">Planned</option>
|
||||
<option value="in_production">In Production</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Calendar Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{view === 'month' && <MonthView date={currentDate} activities={filteredActivities} onSelect={setSelectedActivity} />}
|
||||
{view === 'week' && <WeekView date={currentDate} activities={filteredActivities} onSelect={setSelectedActivity} />}
|
||||
{view === 'day' && <DayView date={currentDate} activities={filteredActivities} onSelect={setSelectedActivity} />}
|
||||
{view === 'year' && <YearView year={currentDate.getFullYear()} activities={filteredActivities} onSelect={setSelectedActivity} />}
|
||||
{view === 'quarter' && <QuarterView date={currentDate} activities={filteredActivities} onSelect={setSelectedActivity} />}
|
||||
</div>
|
||||
|
||||
{/* Activity Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedActivity && (
|
||||
<ActivityDetail
|
||||
activity={selectedActivity}
|
||||
onClose={() => setSelectedActivity(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthView({ date, activities, onSelect }: { date: Date; activities: Activity[]; onSelect: (a: Activity) => void }) {
|
||||
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const emptyDays = Array.from({ length: firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{emptyDays.map(i => (
|
||||
<div key={`empty-${i}`} className="min-h-[100px] bg-gray-50 rounded-lg" />
|
||||
))}
|
||||
{days.map(day => {
|
||||
const dayActivities = activities.filter(a => {
|
||||
const aDate = new Date(a.date);
|
||||
return aDate.getDate() === day && aDate.getMonth() === date.getMonth();
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className="min-h-[100px] bg-white border rounded-lg p-2 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700">{day}</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{dayActivities.map(activity => {
|
||||
const Icon = channelIcons[activity.channel];
|
||||
const StatusIcon = statusIcons[activity.status];
|
||||
return (
|
||||
<motion.button
|
||||
key={activity.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => onSelect(activity)}
|
||||
className={`w-full text-left px-2 py-1 rounded text-xs flex items-center gap-1.5 ${statusColors[activity.status]}`}
|
||||
>
|
||||
{Icon && <Icon size={12} />}
|
||||
<span className="truncate flex-1">{activity.activityType}</span>
|
||||
<StatusIcon size={10} />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekView({ date, activities, onSelect }: { date: Date; activities: Activity[]; onSelect: (a: Activity) => void }) {
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay() + 1);
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
return day;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{weekDays.map((day, i) => {
|
||||
const dayActivities = activities.filter(a => {
|
||||
const aDate = new Date(a.date);
|
||||
return aDate.toDateString() === day.toDateString();
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={i} className="bg-white border rounded-lg p-3 min-h-[400px]">
|
||||
<div className="text-center mb-3">
|
||||
<div className="text-xs text-gray-500 uppercase">{day.toLocaleDateString('sv-SE', { weekday: 'short' })}</div>
|
||||
<div className="text-lg font-bold">{day.getDate()}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dayActivities.map(activity => {
|
||||
const Icon = channelIcons[activity.channel];
|
||||
return (
|
||||
<motion.button
|
||||
key={activity.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => onSelect(activity)}
|
||||
className={`w-full text-left p-2 rounded-lg text-xs ${statusColors[activity.status]}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{Icon && <Icon size={12} />}
|
||||
<span className="font-medium">{activity.time}</span>
|
||||
</div>
|
||||
<div className="truncate">{activity.campaign}</div>
|
||||
<div className="truncate text-gray-600">{activity.purpose}</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayView({ date, activities, onSelect }: { date: Date; activities: Activity[]; onSelect: (a: Activity) => void }) {
|
||||
const dayActivities = activities.filter(a => {
|
||||
const aDate = new Date(a.date);
|
||||
return aDate.toDateString() === date.toDateString();
|
||||
}).sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
||||
|
||||
const timeSlots = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="text-xl font-bold">{date.toLocaleDateString('sv-SE', { weekday: 'long', day: 'numeric', month: 'long' })}</h2>
|
||||
</div>
|
||||
{timeSlots.map(time => {
|
||||
const slotActivities = dayActivities.filter(a => a.time?.startsWith(time.split(':')[0]));
|
||||
|
||||
return (
|
||||
<div key={time} className="flex gap-4 min-h-[60px]">
|
||||
<div className="w-16 text-right text-sm text-gray-500 pt-2">{time}</div>
|
||||
<div className="flex-1 border-l-2 border-gray-200 pl-4 space-y-2">
|
||||
{slotActivities.map(activity => {
|
||||
const Icon = channelIcons[activity.channel];
|
||||
const StatusIcon = statusIcons[activity.status];
|
||||
return (
|
||||
<motion.button
|
||||
key={activity.id}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
onClick={() => onSelect(activity)}
|
||||
className={`w-full text-left p-3 rounded-lg ${statusColors[activity.status]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon size={16} />}
|
||||
<span className="font-medium">{activity.campaign}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{activity.time}</span>
|
||||
<StatusIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-sm">{activity.purpose}</div>
|
||||
<div className="mt-1 text-xs opacity-75">{activity.channel} → {activity.publicationDestination}</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearView({ year, activities, onSelect }: { year: number; activities: Activity[]; onSelect: (a: Activity) => void }) {
|
||||
const months = Array.from({ length: 12 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{months.map(month => {
|
||||
const monthActivities = activities.filter(a => {
|
||||
const aDate = new Date(a.date);
|
||||
return aDate.getMonth() === month && aDate.getFullYear() === year;
|
||||
});
|
||||
|
||||
const monthName = new Date(year, month).toLocaleDateString('sv-SE', { month: 'long' });
|
||||
|
||||
return (
|
||||
<div key={month} className="bg-white border rounded-lg p-4">
|
||||
<h3 className="font-bold text-lg mb-3 capitalize">{monthName}</h3>
|
||||
<div className="space-y-2">
|
||||
{monthActivities.slice(0, 5).map(activity => (
|
||||
<motion.button
|
||||
key={activity.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => onSelect(activity)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded text-xs ${statusColors[activity.status]}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{new Date(activity.date).getDate()}</span>
|
||||
<span className="truncate">{activity.campaign}</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
{monthActivities.length > 5 && (
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
+{monthActivities.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuarterView({ date, activities, onSelect }: { date: Date; activities: Activity[]; onSelect: (a: Activity) => void }) {
|
||||
const quarter = Math.floor(date.getMonth() / 3) + 1;
|
||||
const quarterMonths = Array.from({ length: 3 }, (_, i) => quarter * 3 - 3 + i);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Q{quarter} {date.getFullYear()}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{quarterMonths.map(month => {
|
||||
const monthActivities = activities.filter(a => {
|
||||
const aDate = new Date(a.date);
|
||||
return aDate.getMonth() === month;
|
||||
});
|
||||
|
||||
const monthName = new Date(date.getFullYear(), month).toLocaleDateString('sv-SE', { month: 'long' });
|
||||
|
||||
return (
|
||||
<div key={month} className="bg-white border rounded-lg p-4">
|
||||
<h3 className="font-bold text-lg mb-3 capitalize">{monthName}</h3>
|
||||
<div className="space-y-2 max-h-[400px] overflow-auto">
|
||||
{monthActivities.map(activity => (
|
||||
<motion.button
|
||||
key={activity.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => onSelect(activity)}
|
||||
className={`w-full text-left p-2 rounded text-xs ${statusColors[activity.status]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{activity.campaign}</span>
|
||||
<span>{new Date(activity.date).getDate()}</span>
|
||||
</div>
|
||||
<div className="mt-1">{activity.purpose}</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityDetail({ activity, onClose }: { activity: Activity; onClose: () => void }) {
|
||||
const Icon = channelIcons[activity.channel];
|
||||
const StatusIcon = statusIcons[activity.status];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && <Icon size={24} className="text-blue-600" />}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{activity.campaign}</h2>
|
||||
<p className="text-sm text-gray-500">{activity.activityType} on {activity.channel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusColors[activity.status]}`}>
|
||||
<StatusIcon size={14} className="inline mr-1" />
|
||||
{activity.status.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{activity.date} {activity.time}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Purpose & CTA */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Purpose</h3>
|
||||
<p className="text-gray-900">{activity.purpose}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Call to Action</h3>
|
||||
<p className="text-blue-600 font-medium">{activity.cta}</p>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Target Audience</h3>
|
||||
<p>{activity.targetAudience}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Product</h3>
|
||||
<p>{activity.product}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publication */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Publication</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={16} />
|
||||
<span>{activity.publicationDestination}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[activity.publicationStatus]}`}>
|
||||
{activity.publicationStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{activity.result && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Results</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{activity.result.impressions !== undefined && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{activity.result.impressions.toLocaleString()}</div>
|
||||
<div className="text-xs text-blue-600">Impressions</div>
|
||||
</div>
|
||||
)}
|
||||
{activity.result.engagement !== undefined && (
|
||||
<div className="green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{activity.result.engagement}</div>
|
||||
<div className="text-xs text-green-600">Engagement</div>
|
||||
</div>
|
||||
)}
|
||||
{activity.result.clicks !== undefined && (
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{activity.result.clicks}</div>
|
||||
<div className="text-xs text-purple-600">Clicks</div>
|
||||
</div>
|
||||
)}
|
||||
{activity.result.conversions !== undefined && (
|
||||
<div className="bg-orange-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{activity.result.conversions}</div>
|
||||
<div className="text-xs text-orange-600">Conversions</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignee */}
|
||||
{activity.assignee && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Assigned to</h3>
|
||||
<p>{activity.assignee}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<button className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Edit Activity
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50">
|
||||
Preview Content
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50">
|
||||
Publish Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Calendar,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Eye,
|
||||
MousePointer,
|
||||
ShoppingCart,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { mockCampaigns, mockActivities } from '../data/mockPlan';
|
||||
|
||||
export function MarketingControlTower() {
|
||||
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'year'>('30d');
|
||||
|
||||
const campaigns = mockCampaigns;
|
||||
const activities = mockActivities;
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
totalCampaigns: campaigns.length,
|
||||
activeCampaigns: campaigns.filter(c => c.status === 'active').length,
|
||||
plannedActivities: activities.filter(a => a.status === 'planned').length,
|
||||
inProduction: activities.filter(a => a.status === 'in_production').length,
|
||||
scheduled: activities.filter(a => a.status === 'scheduled').length,
|
||||
published: activities.filter(a => a.status === 'published').length,
|
||||
failed: activities.filter(a => a.status === 'failed').length,
|
||||
totalImpressions: activities.reduce((sum, a) => sum + (a.result?.impressions || 0), 0),
|
||||
totalEngagement: activities.reduce((sum, a) => sum + (a.result?.engagement || 0), 0),
|
||||
totalClicks: activities.reduce((sum, a) => sum + (a.result?.clicks || 0), 0),
|
||||
totalConversions: activities.reduce((sum, a) => sum + (a.result?.conversions || 0), 0),
|
||||
};
|
||||
|
||||
const upcomingActivities = activities
|
||||
.filter(a => new Date(a.date) >= new Date())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Marketing Control Tower</h1>
|
||||
<p className="text-gray-500">Real-time overview of all marketing operations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-gray-100 rounded-lg p-1">
|
||||
{(['7d', '30d', '90d', 'year'] as const).map(range => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
timeRange === range ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{range === '7d' ? '7 days' : range === '30d' ? '30 days' : range === '90d' ? '90 days' : 'Year'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white border rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Zap size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.activeCampaigns}</div>
|
||||
<div className="text-sm text-gray-500">Active Campaigns</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white border rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Clock size={20} className="text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.inProduction}</div>
|
||||
<div className="text-sm text-gray-500">In Production</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white border rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle2 size={20} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.published}</div>
|
||||
<div className="text-sm text-gray-500">Published</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white border rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<AlertCircle size={20} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.failed}</div>
|
||||
<div className="text-sm text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Eye size={16} className="text-blue-600" />
|
||||
<span className="text-sm text-gray-500">Impressions</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.totalImpressions.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MousePointer size={16} className="text-green-600" />
|
||||
<span className="text-sm text-gray-500">Engagement</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.totalEngagement}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BarChart3 size={16} className="text-purple-600" />
|
||||
<span className="text-sm text-gray-500">Clicks</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.totalClicks}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShoppingCart size={16} className="text-orange-600" />
|
||||
<span className="text-sm text-gray-500">Conversions</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{stats.totalConversions}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Progress */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Active Campaigns */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Active Campaigns</h2>
|
||||
<div className="space-y-4">
|
||||
{campaigns.filter(c => c.status === 'active').map(campaign => (
|
||||
<div key={campaign.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">{campaign.name}</h3>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">{campaign.description}</p>
|
||||
<div className="space-y-2">
|
||||
{campaign.goals?.map((goal, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>{goal.metric}</span>
|
||||
<span>{goal.current?.toLocaleString()} / {goal.target.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((goal.current || 0) / goal.target) * 100}%` }}
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Activities */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Upcoming Activities</h2>
|
||||
<div className="space-y-3">
|
||||
{upcomingActivities.map(activity => (
|
||||
<div key={activity.id} className="flex items-center gap-4 p-3 border rounded-lg">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<Calendar size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{activity.campaign}</h4>
|
||||
<p className="text-sm text-gray-500">{activity.purpose}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium">{activity.date}</div>
|
||||
<div className="text-xs text-gray-500">{activity.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Performance */}
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Channel Performance</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{['instagram', 'facebook', 'linkedin', 'newsletter'].map(channel => {
|
||||
const channelActivities = activities.filter(a => a.channel === channel);
|
||||
const published = channelActivities.filter(a => a.status === 'published').length;
|
||||
const impressions = channelActivities.reduce((sum, a) => sum + (a.result?.impressions || 0), 0);
|
||||
|
||||
return (
|
||||
<div key={channel} className="border rounded-lg p-4">
|
||||
<h3 className="font-medium capitalize mb-2">{channel}</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Activities</span>
|
||||
<span>{channelActivities.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Published</span>
|
||||
<span>{published}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Impressions</span>
|
||||
<span>{impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { MarketingPlan, Activity, Campaign, MarketingBattery } from '../types';
|
||||
|
||||
export const mockMarketingPlan: MarketingPlan = {
|
||||
id: 'plan-2024',
|
||||
year: 2024,
|
||||
company: 'Landvex Inc',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-08-08',
|
||||
quarters: [
|
||||
{
|
||||
id: 'q1',
|
||||
quarter: 1,
|
||||
focus: 'Brand Awareness & Product Launch',
|
||||
campaigns: ['c1', 'c2'],
|
||||
activityLevel: 85,
|
||||
goals: [
|
||||
{ metric: 'reach', target: 100000 },
|
||||
{ metric: 'engagement', target: 5000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
quarter: 2,
|
||||
focus: 'Summer Campaign & Lead Generation',
|
||||
campaigns: ['c3'],
|
||||
activityLevel: 90,
|
||||
goals: [
|
||||
{ metric: 'leads', target: 500 },
|
||||
{ metric: 'conversions', target: 50 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
quarter: 3,
|
||||
focus: 'Product Updates & Customer Retention',
|
||||
campaigns: ['c4'],
|
||||
activityLevel: 75,
|
||||
goals: [
|
||||
{ metric: 'retention', target: 85 },
|
||||
{ metric: 'upsell', target: 30 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
quarter: 4,
|
||||
focus: 'Year-End & Holiday Campaign',
|
||||
campaigns: ['c5'],
|
||||
activityLevel: 95,
|
||||
goals: [
|
||||
{ metric: 'revenue', target: 500000 },
|
||||
{ metric: 'new_customers', target: 100 }
|
||||
]
|
||||
}
|
||||
],
|
||||
campaigns: []
|
||||
};
|
||||
|
||||
export const mockCampaigns: Campaign[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'Vinterservice Kampanj',
|
||||
description: 'Komplett vinterservice för bilägare',
|
||||
strategy: 'Fokus på säkerhet och förebyggande underhåll',
|
||||
targetAudience: 'Bilägare i Sverige',
|
||||
cta: 'Boka vinterservice nu',
|
||||
status: 'active',
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2024-03-15',
|
||||
budget: 50000,
|
||||
goals: [
|
||||
{ metric: 'bookings', target: 200, current: 145 },
|
||||
{ metric: 'reach', target: 50000, current: 42000 }
|
||||
],
|
||||
activities: []
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
name: 'ALVA Launch',
|
||||
description: 'Lansering av ALVA diagnosplattform',
|
||||
strategy: 'B2B-fokus på verkstäder och fordonsbolag',
|
||||
targetAudience: 'Fordonsverkstäder',
|
||||
cta: 'Ansök om demo',
|
||||
status: 'active',
|
||||
startDate: '2024-02-01',
|
||||
endDate: '2024-04-30',
|
||||
budget: 100000,
|
||||
goals: [
|
||||
{ metric: 'demos', target: 50, current: 32 },
|
||||
{ metric: 'signups', target: 20, current: 12 }
|
||||
],
|
||||
activities: []
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
name: 'Sommar Erbjudande',
|
||||
description: 'Sommarcheck och AC-service',
|
||||
strategy: 'Säsongserbjudande för sommarförberedelser',
|
||||
targetAudience: 'Bilägare',
|
||||
cta: 'Boka sommarcheck',
|
||||
status: 'planned',
|
||||
startDate: '2024-05-01',
|
||||
endDate: '2024-07-31',
|
||||
budget: 40000,
|
||||
goals: [
|
||||
{ metric: 'bookings', target: 300 },
|
||||
{ metric: 'revenue', target: 150000 }
|
||||
],
|
||||
activities: []
|
||||
}
|
||||
];
|
||||
|
||||
export const mockActivities: Activity[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
date: '2024-08-12',
|
||||
time: '09:00',
|
||||
channel: 'instagram',
|
||||
activityType: 'reel',
|
||||
campaign: 'Vinterservice Kampanj',
|
||||
product: 'Service',
|
||||
targetAudience: 'Bilägare 25-45',
|
||||
purpose: 'Öka medvetenhet om vinterservice',
|
||||
cta: 'Boka nu',
|
||||
status: 'approved',
|
||||
assignee: 'Anna Lind',
|
||||
publicationStatus: 'scheduled',
|
||||
publicationDestination: 'Instagram',
|
||||
metadata: { hashtags: '#vinterservice #bilservice #säkerhet' }
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
date: '2024-08-12',
|
||||
time: '14:00',
|
||||
channel: 'linkedin',
|
||||
activityType: 'article',
|
||||
campaign: 'ALVA Launch',
|
||||
product: 'ALVA',
|
||||
targetAudience: 'Verkstadsägare',
|
||||
purpose: 'Positionera ALVA som branschlösning',
|
||||
cta: 'Läs mer',
|
||||
status: 'in_production',
|
||||
assignee: 'Erik Svensson',
|
||||
publicationStatus: 'draft',
|
||||
publicationDestination: 'LinkedIn',
|
||||
metadata: { topic: 'Digitalisering av fordonsbranschen' }
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
date: '2024-08-13',
|
||||
time: '10:00',
|
||||
channel: 'newsletter',
|
||||
activityType: 'newsletter',
|
||||
campaign: 'Vinterservice Kampanj',
|
||||
product: 'Service',
|
||||
targetAudience: 'Befintliga kunder',
|
||||
purpose: 'Driva bokningar för vinterservice',
|
||||
cta: 'Boka online',
|
||||
status: 'scheduled',
|
||||
assignee: 'Johan Berg',
|
||||
publicationStatus: 'scheduled',
|
||||
publicationDestination: 'Email',
|
||||
metadata: { segment: 'kunder_senaste_12_manader' }
|
||||
},
|
||||
{
|
||||
id: 'a4',
|
||||
date: '2024-08-14',
|
||||
time: '11:00',
|
||||
channel: 'facebook',
|
||||
activityType: 'carousel',
|
||||
campaign: 'Sommar Erbjudande',
|
||||
product: 'AC-service',
|
||||
targetAudience: 'Bilägare 30-55',
|
||||
purpose: 'Visa fördelar med AC-service',
|
||||
cta: 'Se erbjudande',
|
||||
status: 'planned',
|
||||
assignee: 'Anna Lind',
|
||||
publicationStatus: 'draft',
|
||||
publicationDestination: 'Facebook',
|
||||
metadata: { images: 5, format: 'carousel' }
|
||||
},
|
||||
{
|
||||
id: 'a5',
|
||||
date: '2024-08-15',
|
||||
time: '08:00',
|
||||
channel: 'google_business',
|
||||
activityType: 'post',
|
||||
campaign: 'Vinterservice Kampanj',
|
||||
product: 'Service',
|
||||
targetAudience: 'Lokal målgrupp',
|
||||
purpose: 'Lokal synlighet',
|
||||
cta: 'Ring oss',
|
||||
status: 'published',
|
||||
assignee: 'Johan Berg',
|
||||
publicationStatus: 'published',
|
||||
publicationDestination: 'Google Business Profile',
|
||||
result: {
|
||||
impressions: 1200,
|
||||
engagement: 45,
|
||||
clicks: 12
|
||||
},
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
export const mockBattery: MarketingBattery[] = [
|
||||
{
|
||||
id: 'b1',
|
||||
name: 'Vinterservice - Instagram Reel',
|
||||
type: 'template',
|
||||
content: {
|
||||
format: 'reel',
|
||||
duration: '15-30s',
|
||||
hook: 'Visa isig bilruta',
|
||||
cta: 'Boka vinterservice'
|
||||
},
|
||||
tags: ['vinterservice', 'instagram', 'reel', 'video'],
|
||||
usageCount: 12
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
name: 'ALVA - LinkedIn Article',
|
||||
type: 'template',
|
||||
content: {
|
||||
format: 'article',
|
||||
length: '800-1200 words',
|
||||
structure: ['problem', 'solution', 'proof', 'cta'],
|
||||
tone: 'professional'
|
||||
},
|
||||
tags: ['alva', 'linkedin', 'article', 'b2b'],
|
||||
usageCount: 8
|
||||
},
|
||||
{
|
||||
id: 'b3',
|
||||
name: 'Sommarcheck - Newsletter',
|
||||
type: 'template',
|
||||
content: {
|
||||
format: 'newsletter',
|
||||
sections: ['header', 'offer', 'benefits', 'testimonial', 'cta'],
|
||||
subject_line_templates: [
|
||||
'Förbered bilen för sommaren',
|
||||
'Sommarcheck - 20% rabatt'
|
||||
]
|
||||
},
|
||||
tags: ['sommar', 'newsletter', 'email', 'erbjudande'],
|
||||
usageCount: 15
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,202 @@
|
||||
// MARKETING OPERATIONS SYSTEM - TYPES
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
industry: string;
|
||||
products: Product[];
|
||||
markets: string[];
|
||||
brandVoice: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
productAreas: ProductArea[];
|
||||
targetAudiences: string[];
|
||||
seasonality: Seasonality[];
|
||||
}
|
||||
|
||||
export interface ProductArea {
|
||||
id: string;
|
||||
name: string;
|
||||
subProducts: SubProduct[];
|
||||
}
|
||||
|
||||
export interface SubProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
campaigns: Campaign[];
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
strategy: string;
|
||||
targetAudience: string;
|
||||
cta: string;
|
||||
activities: Activity[];
|
||||
status: CampaignStatus;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
budget?: number;
|
||||
goals: CampaignGoal[];
|
||||
}
|
||||
|
||||
export type CampaignStatus =
|
||||
| 'planned'
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface CampaignGoal {
|
||||
metric: string;
|
||||
target: number;
|
||||
current?: number;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
channel: Channel;
|
||||
activityType: ActivityType;
|
||||
campaign?: string;
|
||||
product?: string;
|
||||
productArea?: string;
|
||||
targetAudience: string;
|
||||
purpose: string;
|
||||
cta: string;
|
||||
status: ActivityStatus;
|
||||
assignee?: string;
|
||||
contentAsset?: ContentAsset;
|
||||
publicationStatus: PublicationStatus;
|
||||
publicationDestination: string;
|
||||
metadata: Record<string, any>;
|
||||
result?: ActivityResult;
|
||||
}
|
||||
|
||||
export type Channel =
|
||||
| 'instagram'
|
||||
| 'facebook'
|
||||
| 'linkedin'
|
||||
| 'newsletter'
|
||||
| 'website'
|
||||
| 'google_business'
|
||||
| 'youtube'
|
||||
| 'tiktok'
|
||||
| 'email';
|
||||
|
||||
export type ActivityType =
|
||||
| 'post'
|
||||
| 'story'
|
||||
| 'reel'
|
||||
| 'article'
|
||||
| 'newsletter'
|
||||
| 'ad'
|
||||
| 'video'
|
||||
| 'image'
|
||||
| 'carousel';
|
||||
|
||||
export type ActivityStatus =
|
||||
| 'planned'
|
||||
| 'brief_created'
|
||||
| 'in_production'
|
||||
| 'generated'
|
||||
| 'review_required'
|
||||
| 'approved'
|
||||
| 'scheduled'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'verified'
|
||||
| 'failed'
|
||||
| 'paused'
|
||||
| 'cancelled';
|
||||
|
||||
export type PublicationStatus =
|
||||
| 'draft'
|
||||
| 'ready'
|
||||
| 'scheduled'
|
||||
| 'published'
|
||||
| 'failed';
|
||||
|
||||
export interface ContentAsset {
|
||||
id: string;
|
||||
type: 'text' | 'image' | 'video' | 'carousel' | 'story';
|
||||
content: string;
|
||||
mediaUrl?: string;
|
||||
preview?: string;
|
||||
channelSpecific: ChannelSpecificContent;
|
||||
}
|
||||
|
||||
export interface ChannelSpecificContent {
|
||||
instagram?: string;
|
||||
facebook?: string;
|
||||
linkedin?: string;
|
||||
newsletter?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface ActivityResult {
|
||||
impressions?: number;
|
||||
engagement?: number;
|
||||
clicks?: number;
|
||||
conversions?: number;
|
||||
revenue?: number;
|
||||
roi?: number;
|
||||
}
|
||||
|
||||
export interface MarketingPlan {
|
||||
id: string;
|
||||
year: number;
|
||||
company: string;
|
||||
quarters: Quarter[];
|
||||
campaigns: Campaign[];
|
||||
status: 'draft' | 'approved' | 'active' | 'archived';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Quarter {
|
||||
id: string;
|
||||
quarter: 1 | 2 | 3 | 4;
|
||||
focus: string;
|
||||
campaigns: string[];
|
||||
activityLevel: number;
|
||||
goals: CampaignGoal[];
|
||||
}
|
||||
|
||||
export interface Seasonality {
|
||||
month: number;
|
||||
intensity: 'low' | 'medium' | 'high';
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface MarketingBattery {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'campaign' | 'template' | 'cta' | 'image_format' | 'video_format' | 'text_template';
|
||||
content: any;
|
||||
tags: string[];
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
}
|
||||
|
||||
export interface PublicationJob {
|
||||
id: string;
|
||||
activityId: string;
|
||||
channel: Channel;
|
||||
scheduledTime: string;
|
||||
status: 'queued' | 'processing' | 'published' | 'failed';
|
||||
retryCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CalendarView {
|
||||
type: 'year' | 'quarter' | 'month' | 'week' | 'day';
|
||||
date: Date;
|
||||
activities: Activity[];
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { amosApi } from '@/lib/api'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Cpu,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Model {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
status: string
|
||||
accuracy: number
|
||||
last_trained: string
|
||||
}
|
||||
|
||||
interface Engine {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
version: string
|
||||
uptime: string
|
||||
last_check: string
|
||||
health: string
|
||||
requests_24h: number
|
||||
latency_ms: number
|
||||
error_rate: number
|
||||
models: Model[]
|
||||
}
|
||||
|
||||
interface EngineSummary {
|
||||
total: number
|
||||
healthy: number
|
||||
warning: number
|
||||
critical: number
|
||||
maintenance: number
|
||||
}
|
||||
|
||||
export function AMOSControlPage() {
|
||||
const [engines, setEngines] = useState<Engine[]>([])
|
||||
const [summary, setSummary] = useState<EngineSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [restarting, setRestarting] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchEngines()
|
||||
}, [])
|
||||
|
||||
async function fetchEngines() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await amosApi.engines()
|
||||
setEngines(res.engines || [])
|
||||
setSummary(res.summary || null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load engines')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartEngine(id: string) {
|
||||
setRestarting(id)
|
||||
try {
|
||||
await amosApi.restartEngine(id)
|
||||
// Refresh after restart
|
||||
setTimeout(fetchEngines, 2000)
|
||||
} catch (err) {
|
||||
console.error('Restart failed:', err)
|
||||
} finally {
|
||||
setRestarting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function getHealthVariant(health: string) {
|
||||
switch (health) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'critical':
|
||||
return 'danger'
|
||||
case 'maintenance':
|
||||
return 'default'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function getHealthIcon(health: string) {
|
||||
switch (health) {
|
||||
case 'healthy':
|
||||
return <CheckCircle size={16} className="text-success" />
|
||||
case 'warning':
|
||||
return <AlertTriangle size={16} className="text-warning" />
|
||||
case 'critical':
|
||||
return <XCircle size={16} className="text-danger" />
|
||||
case 'maintenance':
|
||||
return <Clock size={16} className="text-text-secondary" />
|
||||
default:
|
||||
return <Activity size={16} className="text-text-secondary" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[200px]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isAuthError = error.includes('authorization') || error.includes('unauthorized') || error.includes('missing authorization')
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center max-w-md px-4">
|
||||
{isAuthError ? (
|
||||
<>
|
||||
<Server size={48} className="mx-auto text-warning mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">Authentication required</h3>
|
||||
<p className="text-text-secondary mb-4">Please log in to access AMOS Control Panel</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/login'}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={48} className="text-danger mx-auto mb-4" />
|
||||
<p className="text-danger font-medium">{error}</p>
|
||||
<Button onClick={fetchEngines} className="mt-4" icon={<RefreshCw size={16} />}>
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">AMOS Control Panel</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
Monitor and control all AMOS engines
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchEngines} icon={<RefreshCw size={16} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{summary.total}</p>
|
||||
<p className="text-xs text-text-secondary">Total Engines</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{summary.healthy}</p>
|
||||
<p className="text-xs text-text-secondary">Healthy</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{summary.warning}</p>
|
||||
<p className="text-xs text-text-secondary">Warning</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
|
||||
<XCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{summary.critical + summary.maintenance}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Critical/Maintenance</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Engines Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{engines.map((engine) => (
|
||||
<Card key={engine.id} className="relative">
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getHealthIcon(engine.health)}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{engine.name}</h3>
|
||||
<p className="text-xs text-text-secondary">{engine.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getHealthVariant(engine.health)}>{engine.health}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-2 bg-bg rounded-lg">
|
||||
<p className="text-xs text-text-secondary">Requests (24h)</p>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{engine.requests_24h.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-bg rounded-lg">
|
||||
<p className="text-xs text-text-secondary">Latency</p>
|
||||
<p className="text-sm font-medium text-text-primary">{engine.latency_ms.toFixed(1)} ms</p>
|
||||
</div>
|
||||
<div className="p-2 bg-bg rounded-lg">
|
||||
<p className="text-xs text-text-secondary">Error Rate</p>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{(engine.error_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-bg rounded-lg">
|
||||
<p className="text-xs text-text-secondary">Uptime</p>
|
||||
<p className="text-sm font-medium text-text-primary">{engine.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{engine.models && engine.models.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase">Models</p>
|
||||
{engine.models.map((model) => (
|
||||
<div key={model.id} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu size={14} className="text-text-secondary" />
|
||||
<span className="text-text-primary">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">{(model.accuracy * 100).toFixed(0)}%</span>
|
||||
<Badge variant={model.status === 'active' ? 'success' : 'warning'} size="sm">
|
||||
{model.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2 border-t border-border/40">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
icon={<RefreshCw size={14} />}
|
||||
disabled={restarting === engine.id}
|
||||
onClick={() => restartEngine(engine.id)}
|
||||
>
|
||||
{restarting === engine.id ? 'Restarting...' : 'Restart Engine'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { journalApi, financeApi } from '@/lib/api'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Building2,
|
||||
Upload,
|
||||
Link2,
|
||||
History,
|
||||
} from 'lucide-react'
|
||||
import { MobileCard, MobileCardRow, MobileCardBadge } from '@/components/ui/MobileCard'
|
||||
import { BankAccountCard } from '@/components/banking/BankAccountCard'
|
||||
import { TransactionList } from '@/components/banking/TransactionList'
|
||||
import { StatementUpload } from '@/components/banking/StatementUpload'
|
||||
import { bankAccounts, recentTransactions, statements } from '@/data/bankAccounts'
|
||||
import { BankAccount, BankTransaction, BankStatement } from '@/types/bank'
|
||||
|
||||
interface JournalEntry {
|
||||
id: string
|
||||
entry_number: number
|
||||
description: string
|
||||
entry_date: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
period: string
|
||||
fiscal_year: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AccountBalance {
|
||||
code: string
|
||||
name: string
|
||||
type: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
export function AccountingPage() {
|
||||
const [activeTab, setActiveTab] = useState('banks')
|
||||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||||
const [accounts, setAccounts] = useState<AccountBalance[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedAccount, setSelectedAccount] = useState<string | null>(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [journalRes, accountsRes] = await Promise.all([
|
||||
journalApi.entries(),
|
||||
financeApi.accounts(),
|
||||
])
|
||||
setEntries(journalRes.entries || [])
|
||||
setAccounts((accountsRes as { accounts: AccountBalance[] }).accounts || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = (accountId: string) => {
|
||||
alert(`Synkar konto ${accountId}... (API-koppling krävs)`)
|
||||
}
|
||||
|
||||
const handleConnect = (accountId: string) => {
|
||||
alert(`Kopplar API för konto ${accountId}...`)
|
||||
}
|
||||
|
||||
const handleUpload = (file: File, accountId: string) => {
|
||||
alert(`Laddar upp ${file.name} för konto ${accountId}...`)
|
||||
}
|
||||
|
||||
const handleMatch = (txId: string) => {
|
||||
alert(`Matchar transaktion ${txId} med verifikat...`)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const postedCount = entries.filter((e) => e.status === 'posted').length
|
||||
const totalBalance = accounts.reduce((sum, a) => sum + a.balance, 0)
|
||||
const totalBankBalance = bankAccounts.reduce((sum, a) => sum + a.balance, 0)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'banks', label: 'Bankkonton', icon: Building2 },
|
||||
{ id: 'transactions', label: 'Transaktioner', icon: ArrowRightLeft },
|
||||
{ id: 'ledger', label: 'Huvudbok', icon: BookOpen },
|
||||
{ id: 'accounts', label: 'Konton', icon: CheckCircle },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Accounting</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
Bankkoppling, dubbel bokföring — riktig data från aamos-ledger
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Building2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Bankkonton</p>
|
||||
<p className="text-lg font-semibold text-text-primary">{bankAccounts.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Banktotalt</p>
|
||||
<p className="text-lg font-semibold text-text-primary">
|
||||
{formatCurrency(totalBankBalance)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<BookOpen size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Verifikat</p>
|
||||
<p className="text-lg font-semibold text-text-primary">{entries.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Bokfört</p>
|
||||
<p className="text-lg font-semibold text-text-primary">{postedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border">
|
||||
<div className="flex gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary bg-primary-light border-b-2 border-primary'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'banks' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Bankkonton</h2>
|
||||
<button
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Ladda upp kontoutdrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showUpload && (
|
||||
<Card className="p-6">
|
||||
<StatementUpload
|
||||
accountId={selectedAccount || bankAccounts[0].id}
|
||||
accountName={bankAccounts.find(a => a.id === (selectedAccount || bankAccounts[0].id))?.name || ''}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{bankAccounts.map((account) => (
|
||||
<BankAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSync={handleSync}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Kontoutdragshistorik */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<History size={18} className="text-text-secondary" />
|
||||
<h3 className="font-semibold text-text-primary">Importerade kontoutdrag</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{statements.map((stmt) => (
|
||||
<div
|
||||
key={stmt.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-bg border border-border hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-light flex items-center justify-center text-primary">
|
||||
<Upload size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{stmt.fileName}</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{stmt.periodStart} — {stmt.periodEnd} • {stmt.transactionCount} transaktioner
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={stmt.status === 'completed' ? 'default' : 'warning'}>
|
||||
{stmt.status === 'completed' ? 'Klart' : 'Bearbetar'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transactions' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Senaste transaktioner</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedAccount || ''}
|
||||
onChange={(e) => setSelectedAccount(e.target.value || null)}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-white text-sm"
|
||||
>
|
||||
<option value="">Alla konton</option>
|
||||
{bankAccounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>{account.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionList
|
||||
transactions={selectedAccount
|
||||
? recentTransactions.filter(t => t.accountId === selectedAccount)
|
||||
: recentTransactions
|
||||
}
|
||||
onMatch={handleMatch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ledger' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Huvudbok</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Ver.nr
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Beskrivning
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Datum
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Period
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-bg transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">
|
||||
{entry.entry_number}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">
|
||||
{entry.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{formatDate(entry.entry_date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{entry.period}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={entry.status === 'posted' ? 'default' : 'warning'}
|
||||
>
|
||||
{entry.status === 'posted' ? 'Bokförd' : 'Utkast'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'accounts' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Konton</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Kod
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Namn
|
||||
</th>
|
||||
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Typ
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
|
||||
Saldo
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{accounts.map((account) => (
|
||||
<tr key={account.code} className="hover:bg-bg transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">
|
||||
{account.code}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-primary">{account.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{account.type}</td>
|
||||
<td className="px-4 py-3 text-sm text-right font-medium text-text-primary">
|
||||
{formatCurrency(account.balance)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Send, Bot, User, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export function AlvaPage() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hej! Jag är Alva, din AI-assistent. Jag kan hjälpa dig med:\n\n• Analysera data och rapporter\n• Skriva och granska avtal\n• Besvara frågor om BOC\n• Hjälpa med email och kommunikation\n• Ge råd om affärsbeslut\n\nVad kan jag hjälpa dig med idag?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
// TODO: Connect to real AI backend
|
||||
// For now, simulate response
|
||||
setTimeout(() => {
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: generateResponse(input),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
setLoading(false)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const generateResponse = (userInput: string): string => {
|
||||
const lower = userInput.toLowerCase()
|
||||
if (lower.includes('hej') || lower.includes('hallå')) {
|
||||
return 'Hej! Vad kan jag hjälpa dig med?'
|
||||
}
|
||||
if (lower.includes('avtal') || lower.includes('kontrakt')) {
|
||||
return 'Jag kan hjälpa dig att granska avtal, skapa nya mallar, eller analysera befintliga kontrakt. Vill du att jag tittar på ett specifikt avtal?'
|
||||
}
|
||||
if (lower.includes('mail') || lower.includes('email')) {
|
||||
return 'Jag kan hjälpa dig att skriva professionella mail, förbättra befintliga utkast, eller analysera inkommande meddelanden. Vad behöver du hjälp med?'
|
||||
}
|
||||
if (lower.includes('rapport') || lower.includes('analys')) {
|
||||
return 'Jag kan analysera data, skapa rapporter, eller hjälpa dig tolka finansiella siffror. Vilken typ av analys behöver du?'
|
||||
}
|
||||
return 'Jag förstår. Jag kan hjälpa dig med detta. Kan du ge mig mer kontext eller specificera vad du vill ha hjälp med?'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Alva</h1>
|
||||
<p className="text-sm text-text-secondary">Din AI-assistent</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
message.role === 'assistant'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-surface text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'assistant' ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<Card
|
||||
className={`max-w-[80%] ${
|
||||
message.role === 'user' ? 'bg-primary text-white' : ''
|
||||
}`}
|
||||
padding="sm"
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Bot size={16} className="text-primary" />
|
||||
</div>
|
||||
<Card className="max-w-[80%]" padding="sm">
|
||||
<div className="flex items-center gap-2 text-text-secondary">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-sm">Tänker...</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2 pt-4 border-t border-border">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Skriv ett meddelande..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
icon={loading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
Skicka
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { complianceApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {
|
||||
FileCheck,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
Scale,
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
export function CompliancePage() {
|
||||
const [activeTab, setActiveTab] = useState('iso')
|
||||
const [isoCerts, setIsoCerts] = useState<any[]>([])
|
||||
const [gdprRecords, setGdprRecords] = useState<any[]>([])
|
||||
const [risks, setRisks] = useState<any[]>([])
|
||||
const [legalCases, setLegalCases] = useState<any[]>([])
|
||||
const [policies, setPolicies] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'iso', label: 'ISO Certifications', icon: FileCheck },
|
||||
{ key: 'gdpr', label: 'GDPR / Privacy', icon: Lock },
|
||||
{ key: 'risks', label: 'Risk Register', icon: AlertTriangle },
|
||||
{ key: 'legal', label: 'Legal Cases', icon: Scale },
|
||||
{ key: 'policies', label: 'Policies', icon: BookOpen },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [isoRes, gdprRes, risksRes, legalRes, policiesRes] = await Promise.all([
|
||||
complianceApi.iso(),
|
||||
complianceApi.gdpr(),
|
||||
complianceApi.risks(),
|
||||
complianceApi.legalCases(),
|
||||
complianceApi.policies(),
|
||||
])
|
||||
setIsoCerts(isoRes.certifications || [])
|
||||
setGdprRecords(gdprRes.records || [])
|
||||
setRisks(risksRes.risks || [])
|
||||
setLegalCases(legalRes.cases || [])
|
||||
setPolicies(policiesRes.policies || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load compliance data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
case 'signed':
|
||||
case 'mitigated':
|
||||
return 'success'
|
||||
case 'in_progress':
|
||||
case 'pending':
|
||||
case 'draft':
|
||||
return 'warning'
|
||||
case 'critical':
|
||||
case 'failed':
|
||||
case 'high':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskColor(score: number) {
|
||||
if (score >= 15) return 'text-danger'
|
||||
if (score >= 10) return 'text-warning'
|
||||
return 'text-success'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Compliance & Legal</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
ISO, GDPR, risk management, legal cases & policies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ISO Tab */}
|
||||
{activeTab === 'iso' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<FileCheck size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{isoCerts.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{isoCerts.filter((c) => c.status === 'active').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{isoCerts.filter((c) => c.status === 'in_progress').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">In Progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{isoCerts.reduce((sum, c) => sum + c.major_findings, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Major Findings</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{isoCerts.map((cert) => (
|
||||
<Card key={cert.id}>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{cert.standard}</h3>
|
||||
<p className="text-xs text-text-secondary">{cert.name}</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(cert.status)}>{cert.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Issuer</p>
|
||||
<p className="text-text-primary">{cert.issuer}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Auditor</p>
|
||||
<p className="text-text-primary">{cert.auditor}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Issued</p>
|
||||
<p className="text-text-primary">{cert.issued_at ? formatDate(cert.issued_at) : 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Expires</p>
|
||||
<p className="text-text-primary">{cert.expires_at ? formatDate(cert.expires_at) : 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-bg rounded-lg">
|
||||
<p className="text-xs text-text-secondary mb-1">Scope</p>
|
||||
<p className="text-sm text-text-primary">{cert.scope}</p>
|
||||
</div>
|
||||
|
||||
{cert.findings > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-text-secondary">Findings: {cert.findings}</span>
|
||||
{cert.major_findings > 0 && (
|
||||
<span className="text-danger">Major: {cert.major_findings}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GDPR Tab */}
|
||||
{activeTab === 'gdpr' && (
|
||||
<div className="space-y-6">
|
||||
{gdprRecords.map((record) => (
|
||||
<Card key={record.id}>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{record.purpose}</h3>
|
||||
<Badge variant={record.impact_assessment ? 'success' : 'warning'}>
|
||||
{record.impact_assessment ? 'DPIA Done' : 'No DPIA'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Legal Basis</p>
|
||||
<p className="text-text-primary">{record.legal_basis}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Retention</p>
|
||||
<p className="text-text-primary">{record.retention}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary mb-2">Data Types</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{record.data_types.map((type: string, i: number) => (
|
||||
<Badge key={i} variant="default" size="sm">{type}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary mb-2">Processors</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{record.processors.map((proc: string, i: number) => (
|
||||
<Badge key={i} variant="default" size="sm">{proc}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className={record.dpa_exists ? 'text-success' : 'text-danger'}>
|
||||
DPA: {record.dpa_exists ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={record.cross_border ? 'text-warning' : 'text-text-secondary'}>
|
||||
Cross-border: {record.cross_border ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risks Tab */}
|
||||
{activeTab === 'risks' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{risks.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total Risks</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{risks.filter((r) => r.status === 'active').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{risks.filter((r) => r.status === 'mitigated').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Mitigated</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{risks.filter((r) => r.status === 'monitored').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Monitored</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Risk Register" subtitle="Identified risks and mitigations" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Risk</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Category</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">P</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">I</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">Score</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{risks.map((risk) => (
|
||||
<tr key={risk.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4">
|
||||
<div>
|
||||
<p className="text-sm text-text-primary">{risk.description}</p>
|
||||
<p className="text-xs text-text-secondary">{risk.mitigation}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">{risk.category}</td>
|
||||
<td className="py-3.5 px-4 text-center text-sm text-text-primary">{risk.probability}</td>
|
||||
<td className="py-3.5 px-4 text-center text-sm text-text-primary">{risk.impact}</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className={`text-sm font-semibold ${getRiskColor(risk.score)}`}>
|
||||
{risk.score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(risk.status)}>{risk.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">{risk.owner}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal Cases Tab */}
|
||||
{activeTab === 'legal' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Scale size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{legalCases.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total Cases</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{legalCases.filter((c) => c.status === 'active').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{legalCases.reduce((sum, c) => sum + c.value, 0).toLocaleString()} SEK
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Exposure</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{legalCases.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{c.title}</h3>
|
||||
<p className="text-xs text-text-secondary">{c.opposing_party}</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(c.status)}>{c.status}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">{c.description}</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-text-secondary">Type: {c.type}</span>
|
||||
<span className="text-text-secondary">Lawyer: {c.lawyer}</span>
|
||||
<span className="text-text-secondary">Opened: {formatDate(c.opened_at)}</span>
|
||||
</div>
|
||||
{c.value > 0 && (
|
||||
<p className="text-sm font-medium text-danger">
|
||||
Exposure: {c.value.toLocaleString()} {c.currency}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Policies Tab */}
|
||||
{activeTab === 'policies' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="Policies" subtitle="Corporate policies and procedures" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Policy</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Category</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Version</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Approved</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies.map((policy) => (
|
||||
<tr key={policy.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen size={16} className="text-text-secondary" />
|
||||
<span className="text-sm text-text-primary">{policy.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">{policy.category}</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">{policy.version}</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(policy.status)}>{policy.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">
|
||||
{policy.approved_by || 'Not approved'}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
|
||||
{formatDate(policy.review_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { landvexApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
FileText,
|
||||
Shield,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Entity {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
org_number: string
|
||||
country: string
|
||||
city: string
|
||||
address: string
|
||||
status: string
|
||||
founded_at: string
|
||||
parent_id?: string
|
||||
ownership_percent: number
|
||||
ceo: string
|
||||
board_members: Person[]
|
||||
employees: number
|
||||
revenue: number
|
||||
currency: string
|
||||
tax_status: string
|
||||
compliance_status: string
|
||||
}
|
||||
|
||||
interface Person {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
email: string
|
||||
phone: string
|
||||
nationality: string
|
||||
since: string
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
entity_id: string
|
||||
status: string
|
||||
created_at: string
|
||||
expires_at?: string
|
||||
signed_by: string[]
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ComplianceItem {
|
||||
id: string
|
||||
entity_id: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
due_date: string
|
||||
completed_at?: string
|
||||
responsible: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
export function LandvexPage() {
|
||||
const [activeTab, setActiveTab] = useState('entities')
|
||||
const [entities, setEntities] = useState<Entity[]>([])
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [compliance, setCompliance] = useState<ComplianceItem[]>([])
|
||||
const [complianceSummary, setComplianceSummary] = useState<any>(null)
|
||||
const [ownership, setOwnership] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'entities', label: 'Entities', icon: Building2 },
|
||||
{ key: 'ownership', label: 'Ownership', icon: TrendingUp },
|
||||
{ key: 'documents', label: 'Documents', icon: FileText },
|
||||
{ key: 'compliance', label: 'Compliance', icon: Shield },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [entitiesRes, documentsRes, complianceRes, ownershipRes] = await Promise.all([
|
||||
landvexApi.entities(),
|
||||
landvexApi.documents(),
|
||||
landvexApi.compliance(),
|
||||
landvexApi.ownership(),
|
||||
])
|
||||
setEntities(entitiesRes.entities || [])
|
||||
setDocuments(documentsRes.documents || [])
|
||||
setCompliance(complianceRes.compliance_items || [])
|
||||
setComplianceSummary(complianceRes.summary || null)
|
||||
setOwnership(ownershipRes.ownership || null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
case 'signed':
|
||||
return 'success'
|
||||
case 'pending':
|
||||
case 'in_progress':
|
||||
case 'draft':
|
||||
return 'warning'
|
||||
case 'inactive':
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function getComplianceIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle size={16} className="text-success" />
|
||||
case 'in_progress':
|
||||
return <Clock size={16} className="text-warning" />
|
||||
case 'pending':
|
||||
return <AlertTriangle size={16} className="text-danger" />
|
||||
default:
|
||||
return <XCircle size={16} className="text-text-secondary" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Landvex Control</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
Corporate structure, ownership & compliance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Entities Tab */}
|
||||
{activeTab === 'entities' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Building2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{entities.length}</p>
|
||||
<p className="text-xs text-text-secondary">Entities</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{new Set(entities.map((e) => e.country)).size}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Countries</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Users size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{entities.reduce((sum, e) => sum + e.employees, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Employees</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{entities.map((entity) => (
|
||||
<Card key={entity.id}>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-primary">{entity.name}</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{entity.org_number} • {entity.city}, {entity.country}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(entity.status)}>{entity.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Type</p>
|
||||
<p className="text-text-primary">{entity.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">CEO</p>
|
||||
<p className="text-text-primary">{entity.ceo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Founded</p>
|
||||
<p className="text-text-primary">{formatDate(entity.founded_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Employees</p>
|
||||
<p className="text-text-primary">{entity.employees}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Board</p>
|
||||
<div className="space-y-2">
|
||||
{entity.board_members.map((person) => (
|
||||
<div key={person.id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<p className="text-text-primary">{person.name}</p>
|
||||
<p className="text-xs text-text-secondary">{person.role}</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{person.since}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Shield size={14} className={entity.compliance_status === 'compliant' ? 'text-success' : 'text-warning'} />
|
||||
<span className="text-xs text-text-secondary">
|
||||
Compliance: {entity.compliance_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ownership Tab */}
|
||||
{activeTab === 'ownership' && ownership && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="Ownership Structure" subtitle="Ultimate Beneficial Owner" />
|
||||
<div className="p-5">
|
||||
{ownership.ultimate_beneficial_owner && (
|
||||
<div className="flex items-center gap-4 p-4 bg-bg rounded-xl mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary text-lg font-semibold">
|
||||
{ownership.ultimate_beneficial_owner.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-text-primary">
|
||||
{ownership.ultimate_beneficial_owner.name}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{ownership.ultimate_beneficial_owner.ownership}% ownership •{' '}
|
||||
{ownership.ultimate_beneficial_owner.nationality}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{ownership.entities?.map((entity: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary text-xs font-medium">
|
||||
{entity.jurisdiction}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text-primary">{entity.name}</p>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{entity.type} • {entity.jurisdiction}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-text-primary">{entity.ownership}%</p>
|
||||
<p className="text-xs text-text-secondary">{entity.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="Documents" subtitle="Corporate documents & agreements" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Title</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Type</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Signed By</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-text-secondary" />
|
||||
<span className="text-sm text-text-primary">{doc.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">{doc.type}</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(doc.status)}>{doc.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">
|
||||
{doc.signed_by?.join(', ') || '-'}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm text-text-secondary">
|
||||
{formatDate(doc.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compliance Tab */}
|
||||
{activeTab === 'compliance' && (
|
||||
<div className="space-y-6">
|
||||
{complianceSummary && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Shield size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.total}</p>
|
||||
<p className="text-xs text-text-secondary">Total Items</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.completed}</p>
|
||||
<p className="text-xs text-text-secondary">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.in_progress}</p>
|
||||
<p className="text-xs text-text-secondary">In Progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.overdue}</p>
|
||||
<p className="text-xs text-text-secondary">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Compliance Items" subtitle="Regulatory requirements & deadlines" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Item</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Type</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Due Date</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Responsible</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{compliance.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getComplianceIcon(item.status)}
|
||||
<span className="text-sm text-text-primary">{item.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">{item.type}</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(item.status)}>{item.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">
|
||||
{formatDate(item.due_date)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-secondary">{item.responsible}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { ComposeModal } from '@/components/mail/ComposeModal'
|
||||
import { Mail, MailOpen, RefreshCw, Inbox, ChevronDown, Send, Reply, Plus } from 'lucide-react'
|
||||
|
||||
interface EmailMessage {
|
||||
uid: number
|
||||
subject: string
|
||||
from: string
|
||||
to: string[]
|
||||
date: string
|
||||
body: string
|
||||
preview: string
|
||||
read: boolean
|
||||
attachments: number
|
||||
}
|
||||
|
||||
export function MailPage() {
|
||||
const [messages, setMessages] = useState<EmailMessage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedMessage, setSelectedMessage] = useState<EmailMessage | null>(null)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [mailboxes, setMailboxes] = useState<string[]>([])
|
||||
const [selectedMailbox, setSelectedMailbox] = useState<string>('all')
|
||||
const [showMailboxDropdown, setShowMailboxDropdown] = useState(false)
|
||||
const [showCompose, setShowCompose] = useState(false)
|
||||
const [replyTo, setReplyTo] = useState<EmailMessage | undefined>(undefined)
|
||||
const { token } = useAuthStore()
|
||||
|
||||
const { user } = useAuthStore()
|
||||
|
||||
const fetchMailboxes = async () => {
|
||||
try {
|
||||
if (!token) return
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
const res = await fetch('/api/v1/mail/mailboxes', { headers })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.ok && data.mailboxes) {
|
||||
// Filter mailboxes based on user role
|
||||
const email = user?.email || ''
|
||||
const role = user?.role || ''
|
||||
|
||||
let filtered: string[]
|
||||
|
||||
// CEO (Erik) sees CEO mailboxes + shared
|
||||
if (email.includes('erik') || role === 'ceo') {
|
||||
filtered = data.mailboxes.filter((mailbox: string) => {
|
||||
return mailbox.startsWith('erik@') ||
|
||||
mailbox === 'info@landvex.com' ||
|
||||
mailbox === 'invoice@landvex.com' ||
|
||||
mailbox === 'hello@quixzoom.com' ||
|
||||
mailbox === 'finance@quixzoom.com' ||
|
||||
mailbox === 'cfo@aamos.systems' ||
|
||||
mailbox.startsWith('recovery@') ||
|
||||
mailbox.startsWith('social@')
|
||||
})
|
||||
}
|
||||
// CTO (Johan) sees CTO mailboxes + shared
|
||||
else if (email.includes('johan') || role === 'cto') {
|
||||
filtered = data.mailboxes.filter((mailbox: string) => {
|
||||
return mailbox.startsWith('johan@') ||
|
||||
mailbox === 'cto@aamos.systems' ||
|
||||
mailbox === 'info@aamos.systems' ||
|
||||
mailbox === 'dev@hypbit.com' ||
|
||||
mailbox.startsWith('recovery@') ||
|
||||
mailbox.startsWith('social@')
|
||||
})
|
||||
}
|
||||
// Default: show all
|
||||
else {
|
||||
filtered = data.mailboxes
|
||||
}
|
||||
|
||||
setMailboxes(filtered)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch mailboxes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
if (!token) {
|
||||
setError('Please log in to access mail')
|
||||
setMessages([])
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
|
||||
const [inboxRes, countRes] = await Promise.all([
|
||||
fetch('/api/v1/mail/inbox?limit=50', { headers }),
|
||||
fetch('/api/v1/mail/unread-count', { headers }),
|
||||
])
|
||||
|
||||
const inboxData = await inboxRes.json()
|
||||
|
||||
if (inboxRes.status === 503) {
|
||||
setError(inboxData.error || 'Mail integration not configured')
|
||||
setMessages([])
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (!inboxRes.ok) {
|
||||
if (inboxRes.status === 401) {
|
||||
setError('Please log in to access mail')
|
||||
setMessages([])
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
throw new Error(inboxData.error || 'Failed to fetch inbox')
|
||||
}
|
||||
|
||||
if (inboxData.ok) {
|
||||
setMessages(inboxData.messages || [])
|
||||
}
|
||||
|
||||
if (countRes.ok) {
|
||||
const countData = await countRes.json()
|
||||
if (countData.ok) {
|
||||
setUnreadCount(countData.count || 0)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load mail')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMailboxes()
|
||||
fetchMessages()
|
||||
}, [])
|
||||
|
||||
const markAsRead = async (uid: number) => {
|
||||
try {
|
||||
if (!token) return
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
await fetch(`/api/v1/mail/message/${uid}/read`, { method: 'POST', headers })
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.uid === uid ? { ...m, read: true } : m
|
||||
))
|
||||
setUnreadCount(prev => Math.max(0, prev - 1))
|
||||
} catch (err) {
|
||||
console.error('Failed to mark as read:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const openMessage = (msg: EmailMessage) => {
|
||||
setSelectedMessage(msg)
|
||||
if (!msg.read) {
|
||||
markAsRead(msg.uid)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter messages by selected mailbox
|
||||
const filteredMessages = selectedMailbox === 'all'
|
||||
? messages
|
||||
: messages.filter(m => m.to.some(t => t.includes(selectedMailbox)))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isConfigError = error.includes('not configured') || error.includes('IMAP')
|
||||
const isAuthError = error.includes('log in') || error.includes('authorization') || error.includes('missing authorization')
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center max-w-md px-4">
|
||||
{isConfigError ? (
|
||||
<>
|
||||
<MailOpen size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">Mail not configured</h3>
|
||||
<p className="text-text-secondary mb-4">{error}</p>
|
||||
<p className="text-sm text-text-tertiary">Contact your administrator to set up IMAP integration.</p>
|
||||
</>
|
||||
) : isAuthError ? (
|
||||
<>
|
||||
<Inbox size={48} className="mx-auto text-warning mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">Authentication required</h3>
|
||||
<p className="text-text-secondary mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/login'}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Inbox size={48} className="mx-auto text-danger mb-4" />
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchMessages}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedMessage) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSelectedMessage(null)}
|
||||
className="p-2 hover:bg-surface rounded-lg"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<Button
|
||||
icon={<Reply size={16} />}
|
||||
onClick={() => {
|
||||
setReplyTo(selectedMessage)
|
||||
setShowCompose(true)
|
||||
}}
|
||||
>
|
||||
Svara
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{selectedMessage.subject}</h2>
|
||||
<p className="text-sm text-text-secondary">From: {selectedMessage.from}</p>
|
||||
<p className="text-sm text-text-secondary">To: {selectedMessage.to.join(', ')}</p>
|
||||
<p className="text-sm text-text-secondary">{formatDate(selectedMessage.date)}</p>
|
||||
</div>
|
||||
{!selectedMessage.read && (
|
||||
<Badge variant="primary">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-subtle pt-4">
|
||||
<p className="text-text-primary whitespace-pre-wrap">{selectedMessage.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ComposeModal
|
||||
isOpen={showCompose}
|
||||
onClose={() => {
|
||||
setShowCompose(false)
|
||||
setReplyTo(undefined)
|
||||
}}
|
||||
replyTo={replyTo ? {
|
||||
uid: replyTo.uid,
|
||||
subject: replyTo.subject,
|
||||
from: replyTo.from,
|
||||
body: replyTo.body,
|
||||
} : undefined}
|
||||
onSent={() => {
|
||||
fetchMessages()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with compose */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-text-primary">Mail</h1>
|
||||
<Button
|
||||
icon={<Send size={16} />}
|
||||
onClick={() => {
|
||||
setReplyTo(undefined)
|
||||
setShowCompose(true)
|
||||
}}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
Nytt meddelande
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyTo(undefined)
|
||||
setShowCompose(true)
|
||||
}}
|
||||
className="sm:hidden p-3 bg-primary text-white rounded-xl hover:bg-primary-hover active:scale-95 transition-all"
|
||||
aria-label="Nytt meddelande"
|
||||
>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mailbox Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMailboxDropdown(!showMailboxDropdown)}
|
||||
className="w-full flex items-center justify-between p-3 bg-surface rounded-lg border border-border hover:border-border-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail size={18} className="text-text-secondary" />
|
||||
<span className="font-medium">
|
||||
{selectedMailbox === 'all' ? 'All Mailboxes' : selectedMailbox}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={`text-text-secondary transition-transform ${showMailboxDropdown ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showMailboxDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => { setSelectedMailbox('all'); setShowMailboxDropdown(false) }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-surface-hover transition-colors ${selectedMailbox === 'all' ? 'bg-accent/10 text-accent' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox size={16} />
|
||||
<span>All Mailboxes</span>
|
||||
<Badge variant="default" className="ml-auto">{messages.length}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
{mailboxes.map((mailbox) => (
|
||||
<button
|
||||
key={mailbox}
|
||||
onClick={() => { setSelectedMailbox(mailbox); setShowMailboxDropdown(false) }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-surface-hover transition-colors ${selectedMailbox === mailbox ? 'bg-accent/10 text-accent' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail size={16} />
|
||||
<span className="text-sm">{mailbox}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox size={18} className="text-text-secondary" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
{filteredMessages.length} messages
|
||||
</span>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="primary">{unreadCount} unread</Badge>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchMessages}
|
||||
className="p-2 hover:bg-surface rounded-lg transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={18} className="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for compose */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyTo(undefined)
|
||||
setShowCompose(true)
|
||||
}}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-primary text-white rounded-full shadow-lg flex items-center justify-center hover:bg-primary-hover active:scale-95 transition-all lg:hidden"
|
||||
aria-label="Nytt meddelande"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
{/* Message List */}
|
||||
<div className="space-y-2">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MailOpen size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<p className="text-text-secondary">No messages</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((msg) => (
|
||||
<button
|
||||
key={msg.uid}
|
||||
onClick={() => openMessage(msg)}
|
||||
className={`w-full text-left p-4 rounded-lg border transition-colors hover:bg-surface-hover ${
|
||||
msg.read
|
||||
? 'bg-surface border-border-subtle'
|
||||
: 'bg-surface border-border hover:border-border-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
{msg.read ? (
|
||||
<MailOpen size={18} className="text-text-tertiary" />
|
||||
) : (
|
||||
<Mail size={18} className="text-accent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{!msg.read && (
|
||||
<Badge variant="primary" className="shrink-0">New</Badge>
|
||||
)}
|
||||
<span className={`font-medium truncate ${msg.read ? 'text-text-secondary' : 'text-text-primary'}`}>
|
||||
{msg.from}
|
||||
</span>
|
||||
<span className="text-text-tertiary text-sm shrink-0">
|
||||
{formatDate(msg.date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`font-medium truncate ${msg.read ? 'text-text-secondary' : 'text-text-primary'}`}>
|
||||
{msg.subject}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-text-tertiary truncate">
|
||||
{msg.preview}
|
||||
</p>
|
||||
|
||||
{msg.attachments > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{msg.attachments} attachment{msg.attachments > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ComposeModal
|
||||
isOpen={showCompose}
|
||||
onClose={() => {
|
||||
setShowCompose(false)
|
||||
setReplyTo(undefined)
|
||||
}}
|
||||
replyTo={replyTo ? {
|
||||
uid: replyTo.uid,
|
||||
subject: replyTo.subject,
|
||||
from: replyTo.from,
|
||||
body: replyTo.body,
|
||||
} : undefined}
|
||||
onSent={() => {
|
||||
fetchMessages()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Mail, Server, Shield, Check, AlertCircle, KeyRound } from 'lucide-react'
|
||||
|
||||
interface MailConfig {
|
||||
email: string
|
||||
password: string
|
||||
server: string
|
||||
port: number
|
||||
useTLS: boolean
|
||||
}
|
||||
|
||||
export function MailSettingsPage() {
|
||||
const [config, setConfig] = useState<MailConfig>({
|
||||
email: 'erik@aamos.systems',
|
||||
password: '',
|
||||
server: 'mail.aamos.systems',
|
||||
port: 993,
|
||||
useTLS: true,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setStatus('idle')
|
||||
try {
|
||||
const response = await fetch('/api/v1/mail/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('success')
|
||||
setMessage('Mail configuration saved successfully')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setMessage('Failed to save configuration')
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setMessage('Network error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setStatus('idle')
|
||||
try {
|
||||
const response = await fetch('/api/v1/mail/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.ok) {
|
||||
setStatus('success')
|
||||
setMessage(`Connection successful! ${data.messageCount || 0} unread messages.`)
|
||||
} else {
|
||||
setStatus('error')
|
||||
setMessage(data.error || 'Connection failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error')
|
||||
setMessage('Network error')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail size={24} className="text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Mail Settings</h1>
|
||||
<p className="text-text-secondary">Connect your IMAP inbox</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status !== 'idle' && (
|
||||
<div className={`p-4 rounded-xl flex items-center gap-3 ${
|
||||
status === 'success' ? 'bg-success-light text-success' : 'bg-danger-light text-danger'
|
||||
}`}>
|
||||
{status === 'success' ? <Check size={20} /> : <AlertCircle size={20} />}
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="IMAP Configuration"
|
||||
subtitle="Enter your mail credentials"
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Email Address</label>
|
||||
<select
|
||||
className="w-full h-10 px-3 rounded-xl border border-border bg-surface text-text-primary"
|
||||
value={config.email}
|
||||
onChange={(e) => setConfig({ ...config, email: e.target.value })}
|
||||
>
|
||||
<option value="erik@aamos.systems">erik@aamos.systems</option>
|
||||
<option value="erik@landvex.com">erik@landvex.com</option>
|
||||
<option value="erik@wavult.com">erik@wavult.com</option>
|
||||
<option value="erik@hypbit.com">erik@hypbit.com</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your mail password"
|
||||
value={config.password}
|
||||
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
Don't remember your password? Contact your admin to reset it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Server</label>
|
||||
<div className="relative">
|
||||
<Server size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={config.server}
|
||||
onChange={(e) => setConfig({ ...config, server: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.port}
|
||||
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 993 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-bg rounded-xl">
|
||||
<Shield size={20} className="text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Use TLS/SSL</p>
|
||||
<p className="text-xs text-text-secondary">Encrypt connection to mail server</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, useTLS: !config.useTLS })}
|
||||
className={`w-12 h-7 rounded-full transition-colors ${
|
||||
config.useTLS ? 'bg-primary' : 'bg-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-full bg-white transition-transform ${
|
||||
config.useTLS ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Forgot Password?"
|
||||
subtitle="Reset your mail password"
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<KeyRound size={24} className="text-warning shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
If you've forgotten your mail password, you can reset it through the Mailu admin panel
|
||||
or contact your system administrator.
|
||||
</p>
|
||||
<a
|
||||
href="https://mail.aamos.systems/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
Open Mailu Admin →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !config.email || !config.password}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !config.email || !config.password}
|
||||
className="flex-1"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Calendar,
|
||||
User,
|
||||
Flag,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
X,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'todo' | 'in_progress' | 'review' | 'done'
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||
assignee?: string
|
||||
due_date?: string
|
||||
tags: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
progress: number
|
||||
tasks: Task[]
|
||||
members: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const statusColumns = [
|
||||
{ id: 'todo', label: 'Att göra', icon: Circle, color: 'bg-gray-100' },
|
||||
{ id: 'in_progress', label: 'Pågående', icon: Clock, color: 'bg-blue-50' },
|
||||
{ id: 'review', label: 'Granskning', icon: Flag, color: 'bg-yellow-50' },
|
||||
{ id: 'done', label: 'Klart', icon: CheckCircle2, color: 'bg-green-50' },
|
||||
]
|
||||
|
||||
const priorityColors = {
|
||||
low: 'bg-gray-100 text-gray-700',
|
||||
medium: 'bg-blue-100 text-blue-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
urgent: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
low: 'Låg',
|
||||
medium: 'Medium',
|
||||
high: 'Hög',
|
||||
urgent: 'Akut',
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null)
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
const [showCreateProject, setShowCreateProject] = useState(false)
|
||||
const { token } = useAuthStore()
|
||||
|
||||
const [newTask, setNewTask] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium' as const,
|
||||
status: 'todo' as const,
|
||||
assignee: '',
|
||||
due_date: '',
|
||||
tags: '',
|
||||
})
|
||||
|
||||
const [newProject, setNewProject] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/projects')
|
||||
if (!res.ok) throw new Error('Failed to fetch projects')
|
||||
const data = await res.json()
|
||||
setProjects(data)
|
||||
if (data.length > 0 && !selectedProject) {
|
||||
setSelectedProject(data[0])
|
||||
fetchTasks(data[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTasks = async (projectId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/issues?project_id=${projectId}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch tasks')
|
||||
const data = await res.json()
|
||||
// Map issues to tasks format
|
||||
const mappedTasks: Task[] = data.map((issue: any) => ({
|
||||
id: issue.id,
|
||||
title: issue.summary || issue.title || 'Untitled',
|
||||
description: issue.description || '',
|
||||
status: mapIssueStatus(issue.status),
|
||||
priority: mapIssuePriority(issue.priority),
|
||||
assignee: issue.assignee,
|
||||
due_date: issue.due_date,
|
||||
tags: issue.tags || [],
|
||||
created_at: issue.created_at,
|
||||
}))
|
||||
setTasks(mappedTasks)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tasks:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const mapIssueStatus = (status: string): Task['status'] => {
|
||||
switch (status) {
|
||||
case 'backlog':
|
||||
case 'todo':
|
||||
return 'todo'
|
||||
case 'in_progress':
|
||||
return 'in_progress'
|
||||
case 'review':
|
||||
return 'review'
|
||||
case 'done':
|
||||
case 'resolved':
|
||||
case 'closed':
|
||||
return 'done'
|
||||
default:
|
||||
return 'todo'
|
||||
}
|
||||
}
|
||||
|
||||
const mapIssuePriority = (priority: string): Task['priority'] => {
|
||||
switch (priority) {
|
||||
case 'low':
|
||||
return 'low'
|
||||
case 'medium':
|
||||
return 'medium'
|
||||
case 'high':
|
||||
return 'high'
|
||||
case 'urgent':
|
||||
case 'critical':
|
||||
return 'urgent'
|
||||
default:
|
||||
return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
}, [])
|
||||
|
||||
const createTask = async () => {
|
||||
if (!newTask.title || !selectedProject) return
|
||||
try {
|
||||
const res = await fetch('/api/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
summary: newTask.title,
|
||||
description: newTask.description,
|
||||
priority: newTask.priority,
|
||||
issue_type: 'task',
|
||||
project_id: selectedProject.id,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create')
|
||||
setShowCreateTask(false)
|
||||
setNewTask({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'todo',
|
||||
assignee: '',
|
||||
due_date: '',
|
||||
tags: '',
|
||||
})
|
||||
fetchTasks(selectedProject.id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create')
|
||||
}
|
||||
}
|
||||
|
||||
const createProject = async () => {
|
||||
if (!newProject.name) return
|
||||
try {
|
||||
// Projects API might not exist yet, create locally
|
||||
const project: Project = {
|
||||
id: `proj-${Date.now()}`,
|
||||
name: newProject.name,
|
||||
description: newProject.description,
|
||||
status: 'active',
|
||||
progress: 0,
|
||||
tasks: [],
|
||||
members: [],
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setProjects([...projects, project])
|
||||
setSelectedProject(project)
|
||||
setShowCreateProject(false)
|
||||
setNewProject({ name: '', description: '' })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTaskStatus = async (taskId: string, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/issues/${taskId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (res.ok && selectedProject) {
|
||||
fetchTasks(selectedProject.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getTasksByStatus = (status: string) => {
|
||||
return tasks.filter((task) => task.status === status)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-12" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[500px]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Projekt</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
{selectedProject ? selectedProject.name : 'Välj ett projekt'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowCreateProject(true)}
|
||||
>
|
||||
Nytt projekt
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowCreateTask(true)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
Ny uppgift
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Selector */}
|
||||
{projects.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setSelectedProject(project)
|
||||
fetchTasks(project.id)
|
||||
}}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
selectedProject?.id === project.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface text-text-secondary hover:bg-bg'
|
||||
}`}
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban Board */}
|
||||
{selectedProject && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statusColumns.map((column) => {
|
||||
const columnTasks = getTasksByStatus(column.id)
|
||||
const Icon = column.icon
|
||||
return (
|
||||
<div key={column.id} className="flex flex-col">
|
||||
{/* Column Header */}
|
||||
<div className={`flex items-center justify-between p-3 rounded-t-xl ${column.color}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className="text-text-secondary" />
|
||||
<span className="font-medium text-sm">{column.label}</span>
|
||||
</div>
|
||||
<Badge variant="default" className="text-xs">
|
||||
{columnTasks.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 bg-surface border border-t-0 rounded-b-xl p-2 space-y-2 min-h-[200px]">
|
||||
{columnTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 bg-bg rounded-lg hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => {
|
||||
// Show task details modal
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<p className="text-sm font-medium text-text-primary flex-1">
|
||||
{task.title}
|
||||
</p>
|
||||
<button className="opacity-0 group-hover:opacity-100 p-1 hover:bg-surface rounded transition-opacity">
|
||||
<MoreHorizontal size={14} className="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-text-secondary mb-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="default"
|
||||
className={`text-xs ${priorityColors[task.priority]}`}
|
||||
>
|
||||
{priorityLabels[task.priority]}
|
||||
</Badge>
|
||||
|
||||
{task.assignee && (
|
||||
<div className="flex items-center gap-1 text-text-tertiary">
|
||||
<User size={12} />
|
||||
<span className="text-xs">{task.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.due_date && (
|
||||
<div className="flex items-center gap-1 mt-2 text-text-tertiary">
|
||||
<Calendar size={12} />
|
||||
<span className="text-xs">
|
||||
{new Date(task.due_date).toLocaleDateString('sv-SE')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick status change */}
|
||||
<div className="flex gap-1 mt-2 pt-2 border-t border-border">
|
||||
{statusColumns
|
||||
.filter((s) => s.id !== task.status)
|
||||
.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
updateTaskStatus(task.id, s.id)
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded bg-surface hover:bg-primary/10 text-text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{columnTasks.length === 0 && (
|
||||
<div className="text-center py-8 text-text-tertiary text-sm">
|
||||
Inga uppgifter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateTask && selectedProject && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Ny uppgift</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateTask(false)}
|
||||
className="p-2 hover:bg-bg rounded-lg"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTask.title}
|
||||
onChange={(e) =>
|
||||
setNewTask({ ...newTask, title: e.target.value })
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
||||
placeholder="Vad ska göras?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Beskrivning
|
||||
</label>
|
||||
<textarea
|
||||
value={newTask.description}
|
||||
onChange={(e) =>
|
||||
setNewTask({ ...newTask, description: e.target.value })
|
||||
}
|
||||
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
|
||||
placeholder="Beskriv uppgiften..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Prioritet
|
||||
</label>
|
||||
<select
|
||||
value={newTask.priority}
|
||||
onChange={(e) =>
|
||||
setNewTask({
|
||||
...newTask,
|
||||
priority: e.target.value as Task['priority'],
|
||||
})
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
||||
>
|
||||
<option value="low">Låg</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">Hög</option>
|
||||
<option value="urgent">Akut</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Tilldelad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTask.assignee}
|
||||
onChange={(e) =>
|
||||
setNewTask({ ...newTask, assignee: e.target.value })
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
||||
placeholder="Namn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Förfallodatum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newTask.due_date}
|
||||
onChange={(e) =>
|
||||
setNewTask({ ...newTask, due_date: e.target.value })
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-4 border-t border-border">
|
||||
<Button className="flex-1" onClick={createTask} disabled={!newTask.title}>
|
||||
Skapa uppgift
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreateTask(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Project Modal */}
|
||||
{showCreateProject && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Nytt projekt</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateProject(false)}
|
||||
className="p-2 hover:bg-bg rounded-lg"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Namn
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProject.name}
|
||||
onChange={(e) =>
|
||||
setNewProject({ ...newProject, name: e.target.value })
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
||||
placeholder="Projektnamn"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Beskrivning
|
||||
</label>
|
||||
<textarea
|
||||
value={newProject.description}
|
||||
onChange={(e) =>
|
||||
setNewProject({ ...newProject, description: e.target.value })
|
||||
}
|
||||
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
|
||||
placeholder="Beskriv projektet..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-4 border-t border-border">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={createProject}
|
||||
disabled={!newProject.name}
|
||||
>
|
||||
Skapa projekt
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreateProject(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
||||
import { quixzoomApi } from '@/lib/api'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Users,
|
||||
MapPin,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Camera,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Zoomer {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
status: string
|
||||
country: string
|
||||
city: string
|
||||
joined_at: string
|
||||
last_active: string
|
||||
total_tasks: number
|
||||
completed_tasks: number
|
||||
rating: number
|
||||
earnings: number
|
||||
payout_method: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
interface FieldData {
|
||||
id: string
|
||||
zoomer_id: string
|
||||
zoomer_name: string
|
||||
type: string
|
||||
status: string
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
address: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
created_at: string
|
||||
processed_at?: string
|
||||
ai_result?: {
|
||||
engine: string
|
||||
confidence: number
|
||||
detections: Array<{
|
||||
label: string
|
||||
confidence: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface Payout {
|
||||
id: string
|
||||
zoomer_id: string
|
||||
zoomer_name: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
method: string
|
||||
period: string
|
||||
created_at: string
|
||||
tax: number
|
||||
fee: number
|
||||
net_amount: number
|
||||
}
|
||||
|
||||
export function QuixzoomPage() {
|
||||
const [activeTab, setActiveTab] = useState('zoomers')
|
||||
const [zoomers, setZoomers] = useState<Zoomer[]>([])
|
||||
const [fieldData, setFieldData] = useState<FieldData[]>([])
|
||||
const [payouts, setPayouts] = useState<Payout[]>([])
|
||||
const [insights, setInsights] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'zoomers', label: 'Zoomers', icon: Users },
|
||||
{ key: 'field-data', label: 'Field Data', icon: Camera },
|
||||
{ key: 'payouts', label: 'Payouts', icon: Wallet },
|
||||
{ key: 'insights', label: 'Insights', icon: Brain },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [zoomersRes, fieldDataRes, payoutsRes, insightsRes] = await Promise.all([
|
||||
quixzoomApi.zoomers(),
|
||||
quixzoomApi.fieldData(),
|
||||
quixzoomApi.payouts(),
|
||||
quixzoomApi.insights(),
|
||||
])
|
||||
setZoomers(zoomersRes.zoomers || [])
|
||||
setFieldData(fieldDataRes.fieldData || [])
|
||||
setPayouts(payoutsRes.payouts || [])
|
||||
setInsights(insightsRes.insights || null)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load data'
|
||||
// Check if it's a configuration error
|
||||
if (msg.includes('not configured')) {
|
||||
setError(msg)
|
||||
} else {
|
||||
setError(msg)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
case 'processed':
|
||||
return 'success'
|
||||
case 'pending':
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'inactive':
|
||||
case 'failed':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isConfigError = error.includes('not configured')
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center max-w-md">
|
||||
{isConfigError ? (
|
||||
<>
|
||||
<Globe size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">quiXzoom not configured</h3>
|
||||
<p className="text-text-secondary mb-4">{error}</p>
|
||||
<p className="text-sm text-text-tertiary">Contact your administrator to set up quiXzoom integration.</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-danger">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">quiXzoom</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
Field intelligence network and zoomer management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zoomers Tab */}
|
||||
{activeTab === 'zoomers' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Users size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{zoomers.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total Zoomers</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{zoomers.filter((z) => z.status === 'active').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Wallet size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{formatCurrency(zoomers.reduce((sum, z) => sum + z.earnings, 0))}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Earnings</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Zoomers" subtitle="All field intelligence agents" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Name</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Location</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Tasks</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Rating</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Earnings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zoomers.map((zoomer) => (
|
||||
<tr key={zoomer.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{zoomer.name}</p>
|
||||
<p className="text-xs text-text-secondary">{zoomer.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center gap-1 text-sm text-text-primary">
|
||||
<MapPin size={14} className="text-text-secondary" />
|
||||
{zoomer.city}, {zoomer.country}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(zoomer.status)}>{zoomer.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
|
||||
{zoomer.completed_tasks}/{zoomer.total_tasks}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
|
||||
{zoomer.rating.toFixed(1)} ★
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm font-medium text-text-primary">
|
||||
{formatCurrency(zoomer.earnings)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Field Data Tab */}
|
||||
{activeTab === 'field-data' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Camera size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{fieldData.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total Observations</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{fieldData.filter((fd) => fd.status === 'processed').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Processed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{fieldData.filter((fd) => fd.status === 'pending').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{fieldData.map((fd) => (
|
||||
<Card key={fd.id}>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{fd.type}</p>
|
||||
<p className="text-xs text-text-secondary">{fd.zoomer_name}</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(fd.status)}>{fd.status}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-text-secondary">
|
||||
<MapPin size={14} />
|
||||
{fd.location.address}, {fd.location.city}
|
||||
</div>
|
||||
{fd.ai_result && (
|
||||
<div className="p-3 bg-bg rounded-lg space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={14} className="text-primary" />
|
||||
<span className="text-xs font-medium text-text-primary">
|
||||
AI Analysis ({fd.ai_result.engine})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-2 bg-primary rounded-full"
|
||||
style={{ width: `${fd.ai_result.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{(fd.ai_result.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{fd.ai_result.detections.map((det, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary">{det.label}</span>
|
||||
<span className="text-text-primary">{(det.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-text-secondary">{formatDate(fd.created_at)}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payouts Tab */}
|
||||
{activeTab === 'payouts' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Wallet size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">{payouts.length}</p>
|
||||
<p className="text-xs text-text-secondary">Total Payouts</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<CheckCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{payouts.filter((p) => p.status === 'completed').length}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{formatCurrency(payouts.reduce((sum, p) => sum + p.amount, 0))}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Amount</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Payouts" subtitle="All zoomer payments" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Zoomer</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Period</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Method</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Amount</th>
|
||||
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payouts.map((payout) => (
|
||||
<tr key={payout.id} className="border-b border-border/50">
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.zoomer_name}</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.period}</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<Badge variant={getStatusVariant(payout.status)}>{payout.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.method}</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
|
||||
{formatCurrency(payout.amount)} {payout.currency}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right text-sm font-medium text-success">
|
||||
{formatCurrency(payout.net_amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insights Tab */}
|
||||
{activeTab === 'insights' && insights && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Camera size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{insights.total_observations?.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Observations</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<Users size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{insights.active_zoomers}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Active Zoomers</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{insights.cities_covered}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Cities</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{insights.countries_covered}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Countries</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Urban Intelligence Indexes */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{insights.urban_sanitation_index && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Urban Sanitation Index"
|
||||
subtitle={`Score: ${insights.urban_sanitation_index.score}/10`}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-3 bg-primary rounded-full"
|
||||
style={{ width: `${(insights.urban_sanitation_index.score / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{insights.urban_sanitation_index.score}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.urban_sanitation_index.cities?.map((city: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-secondary">{city.city}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-2 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-2 bg-primary rounded-full"
|
||||
style={{ width: `${(city.score / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-primary">{city.score}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{insights.reality_gap_index && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Reality Gap Index"
|
||||
subtitle={`Score: ${insights.reality_gap_index.score}/10`}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-3 bg-danger rounded-full"
|
||||
style={{ width: `${(insights.reality_gap_index.score / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{insights.reality_gap_index.score}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Trend: {insights.reality_gap_index.trend}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{insights.reality_contradiction_score && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Reality Contradiction Score"
|
||||
subtitle={`Score: ${insights.reality_contradiction_score.score}/10`}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-3 bg-warning rounded-full"
|
||||
style={{ width: `${(insights.reality_contradiction_score.score / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{insights.reality_contradiction_score.score}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Trend: {insights.reality_contradiction_score.trend}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{insights.implementation_gap_score && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Implementation Gap Score"
|
||||
subtitle={`Score: ${insights.implementation_gap_score.score}/10`}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-3 bg-surface-2 rounded-full">
|
||||
<div
|
||||
className="h-3 bg-success rounded-full"
|
||||
style={{ width: `${(insights.implementation_gap_score.score / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{insights.implementation_gap_score.score}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Trend: {insights.implementation_gap_score.trend}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { Skeleton } from '@/components/ui/Skeleton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import {
|
||||
Instagram,
|
||||
Twitter,
|
||||
Facebook,
|
||||
Linkedin,
|
||||
Globe,
|
||||
Plus,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Heart,
|
||||
Share2,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SocialAccount {
|
||||
id: string
|
||||
platform: string
|
||||
account_name: string
|
||||
display_name: string
|
||||
followers: number
|
||||
following: number
|
||||
posts: number
|
||||
profile_url: string
|
||||
avatar_url: string
|
||||
is_connected: boolean
|
||||
last_synced: string
|
||||
}
|
||||
|
||||
interface SocialPost {
|
||||
id: string
|
||||
platform: string
|
||||
content: string
|
||||
media_url?: string
|
||||
likes: number
|
||||
comments: number
|
||||
shares: number
|
||||
reach: number
|
||||
posted_at: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SocialStats {
|
||||
total_followers: number
|
||||
total_posts: number
|
||||
total_engagement: number
|
||||
accounts: number
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, React.ReactNode> = {
|
||||
instagram: <Instagram size={20} />,
|
||||
twitter: <Twitter size={20} />,
|
||||
facebook: <Facebook size={20} />,
|
||||
linkedin: <Linkedin size={20} />,
|
||||
tiktok: <Globe size={20} />,
|
||||
}
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
instagram: 'bg-gradient-to-br from-purple-500 to-pink-500',
|
||||
twitter: 'bg-blue-500',
|
||||
facebook: 'bg-blue-600',
|
||||
linkedin: 'bg-blue-700',
|
||||
tiktok: 'bg-black',
|
||||
}
|
||||
|
||||
export function SocialMediaPage() {
|
||||
const [accounts, setAccounts] = useState<SocialAccount[]>([])
|
||||
const [posts, setPosts] = useState<SocialPost[]>([])
|
||||
const [stats, setStats] = useState<SocialStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('accounts')
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Hämta konton
|
||||
const accountsRes = await fetch('/api/v1/social/accounts')
|
||||
const accountsData = await accountsRes.json()
|
||||
if (accountsData.ok) {
|
||||
setAccounts(accountsData.accounts || [])
|
||||
}
|
||||
|
||||
// Hämta statistik
|
||||
const statsRes = await fetch('/api/v1/social/stats')
|
||||
const statsData = await statsRes.json()
|
||||
if (statsData.ok) {
|
||||
setStats(statsData.stats)
|
||||
}
|
||||
|
||||
// Hämta inlägg
|
||||
const postsRes = await fetch('/api/v1/social/posts')
|
||||
const postsData = await postsRes.json()
|
||||
if (postsData.ok) {
|
||||
setPosts(postsData.posts || [])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[80px]" />
|
||||
<Skeleton className="h-[80px]" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Social Media</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
Hantera alla sociala media-konton
|
||||
</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />}>Add Account</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
|
||||
<Users size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{stats?.total_followers?.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Followers</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{stats?.total_posts?.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Total Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
|
||||
<Heart size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{stats?.total_engagement?.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Engagement</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-info-light flex items-center justify-center text-info">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-text-primary">
|
||||
{stats?.accounts || 0}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
|
||||
{['accounts', 'posts', 'analytics'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
|
||||
activeTab === tab
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Accounts Tab */}
|
||||
{activeTab === 'accounts' && (
|
||||
<div className="space-y-4">
|
||||
{accounts.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Globe size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">
|
||||
Inga konton kopplade
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Lägg till dina sociala media-konton för att se statistik och hantera inlägg
|
||||
</p>
|
||||
<Button icon={<Plus size={16} />}>Add First Account</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{accounts.map((account) => (
|
||||
<Card key={account.id} className="relative">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl ${platformColors[account.platform] || 'bg-gray-500'} flex items-center justify-center text-white`}>
|
||||
{platformIcons[account.platform] || <Globe size={20} />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-text-primary">
|
||||
{account.display_name || account.account_name}
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary">@{account.account_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={account.is_connected ? 'success' : 'default'}>
|
||||
{account.is_connected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-text-primary">
|
||||
{account.followers.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Followers</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-text-primary">
|
||||
{account.following.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Following</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-text-primary">
|
||||
{account.posts.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary">Posts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-tertiary">
|
||||
Last synced: {account.last_synced ? new Date(account.last_synced).toLocaleDateString() : 'Never'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary"
|
||||
onClick={() => window.open(account.profile_url, '_blank')}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts Tab */}
|
||||
{activeTab === 'posts' && (
|
||||
<div className="space-y-4">
|
||||
{posts.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<MessageSquare size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">
|
||||
Inga inlägg än
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Inlägg visas här när du har kopplat konton
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<Card key={post.id}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg ${platformColors[post.platform] || 'bg-gray-500'} flex items-center justify-center text-white flex-shrink-0`}>
|
||||
{platformIcons[post.platform] || <Globe size={16} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-text-primary mb-2">{post.content}</p>
|
||||
{post.media_url && (
|
||||
<img
|
||||
src={post.media_url}
|
||||
alt="Post media"
|
||||
className="rounded-lg max-h-48 object-cover mb-3"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={14} />
|
||||
{post.likes}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} />
|
||||
{post.comments}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Share2 size={14} />
|
||||
{post.shares}
|
||||
</span>
|
||||
<span>{post.reach.toLocaleString()} reach</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(post.posted_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<Card className="p-8 text-center">
|
||||
<RefreshCw size={48} className="mx-auto text-text-tertiary mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">
|
||||
Analytics kommer snart
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Detaljerad analys av dina sociala media-kanaler
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { TicketWidget } from '@/components/dashboard/TicketWidget';
|
||||
import { ProjectWidget } from '@/components/dashboard/ProjectWidget';
|
||||
import { ServiceHealthWidget } from '@/components/dashboard/ServiceHealthWidget';
|
||||
import { MarketingWidget } from '@/components/dashboard/MarketingWidget';
|
||||
import { SLAWidget } from '@/components/dashboard/SLAWidget';
|
||||
|
||||
export function UnifiedDashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-gray-500">Real-time overview of all operations</p>
|
||||
</div>
|
||||
|
||||
{/* Widgets Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<TicketWidget />
|
||||
<ProjectWidget />
|
||||
<ServiceHealthWidget />
|
||||
<MarketingWidget />
|
||||
<SLAWidget />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<a href="/support" className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors">
|
||||
<span className="text-blue-600">+</span>
|
||||
<span className="text-sm font-medium">New Support Ticket</span>
|
||||
</a>
|
||||
<a href="/projects" className="flex items-center gap-3 p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
<span className="text-purple-600">+</span>
|
||||
<span className="text-sm font-medium">New Project Issue</span>
|
||||
</a>
|
||||
<a href="/marketing" className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors">
|
||||
<span className="text-pink-600">+</span>
|
||||
<span className="text-sm font-medium">New Campaign</span>
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export { AlvaPage } from './AlvaPage';
|
||||
export { MailPage } from './MailPage';
|
||||
export { MarketingPage } from './MarketingPage';
|
||||
export { SupportPage } from './SupportPage';
|
||||
export { ProjectsPage } from './ProjectsPage';
|
||||
export { DashboardPage } from './DashboardPage';
|
||||
export { FinancePage } from './FinancePage';
|
||||
export { HRPage } from './HRPage';
|
||||
export { LegalPage } from './LegalPage';
|
||||
export { CRMPage } from './CRMPage';
|
||||
export { SalesPage } from './SalesPage';
|
||||
export { AutomationPage } from './AutomationPage';
|
||||
export { AMOSControlPage } from './AMOSControlPage';
|
||||
export { QuixzoomPage } from './QuixzoomPage';
|
||||
export { LandvexPage } from './LandvexPage';
|
||||
export { CompliancePage } from './CompliancePage';
|
||||
export { AccountingPage } from './AccountingPage';
|
||||
export { SocialMediaPage } from './SocialMediaPage';
|
||||
export { BriefingPage } from './BriefingPage';
|
||||
export { ProfilePage } from './ProfilePage';
|
||||
export { LoginPage } from './LoginPage';
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-nocheck
|
||||
export interface BankAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
bank: 'revolut' | 'nordea' | 'other';
|
||||
accountNumber: string;
|
||||
iban?: string;
|
||||
currency: string;
|
||||
balance: number;
|
||||
status: 'active' | 'inactive';
|
||||
lastSync?: string;
|
||||
apiConnected: boolean;
|
||||
}
|
||||
|
||||
export interface BankTransaction {
|
||||
id: string;
|
||||
accountId: string;
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
type: 'debit' | 'credit';
|
||||
category?: string;
|
||||
reference?: string;
|
||||
counterparty?: string;
|
||||
importedFrom: 'manual' | 'csv' | 'api';
|
||||
statementId?: string;
|
||||
matchedJournalEntryId?: string;
|
||||
}
|
||||
|
||||
export interface BankStatement {
|
||||
id: string;
|
||||
accountId: string;
|
||||
fileName: string;
|
||||
fileType: 'csv' | 'pdf' | 'xlsx';
|
||||
uploadDate: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
transactionCount: number;
|
||||
status: 'processing' | 'completed' | 'error';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user