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:
Bernt
2026-08-10 12:52:48 +00:00
parent 8921fd1467
commit 78b57273e2
141 changed files with 29192 additions and 180 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+397
View File
@@ -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>
+397
View File
@@ -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>
);
}
+265
View File
@@ -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?`;
}
+125
View File
@@ -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>
);
}
+181
View File
@@ -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>
)}
</>
)
}
+209
View File
@@ -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>
)
}
+49
View File
@@ -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>
)
}
+59
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+115
View File
@@ -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',
},
];
+56
View File
@@ -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();
}
+59
View File
@@ -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)
}
},
}
+255
View File
@@ -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: []
}
];
+163
View File
@@ -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>
);
}
+248
View File
@@ -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
}
];
+202
View File
@@ -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[];
}
+309
View File
@@ -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>
)
}
+397
View File
@@ -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>
)
}
+154
View File
@@ -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>
)
}
+519
View File
@@ -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>
)
}
+483
View File
@@ -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>
)
}
+482
View File
@@ -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>
)
}
+218
View File
@@ -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>
)
}
+567
View File
@@ -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>
)
}
+645
View File
@@ -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>
)
}
+371
View File
@@ -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>
)
}
+51
View File
@@ -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>
);
}
+21
View File
@@ -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';
+41
View File
@@ -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';
}
+23
View File
@@ -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"]
}