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:
@@ -0,0 +1,25 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
interface AgentButtonProps {
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function AgentButton({ onClick, isActive }: AgentButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all ${
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
||||
: 'text-text-secondary hover:bg-bg hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span className="font-medium">AI Assistant</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Send, Bot, User, X, Minimize2, Maximize2, Sparkles } from 'lucide-react';
|
||||
import { getAgentContext, AgentContext } from './AgentContext';
|
||||
|
||||
interface Meddelande {
|
||||
id: string;
|
||||
roll: 'user' | 'assistant';
|
||||
innehall: string;
|
||||
tid: string;
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
rum: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function AgentChat({ rum, isOpen, onToggle }: AgentChatProps) {
|
||||
const context = getAgentContext(rum);
|
||||
const [meddelanden, setMeddelanden] = useState<Meddelande[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
roll: 'assistant',
|
||||
innehall: `Hej! Jag är ${context.titel}. Jag kan hjälpa dig med ${context.kompetenser.join(', ')}. Vad kan jag göra för dig?`,
|
||||
tid: new Date().toISOString()
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [laddar, setLaddar] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [meddelanden]);
|
||||
|
||||
const skickaMeddelande = async () => {
|
||||
if (!input.trim() || laddar) return;
|
||||
|
||||
const userMeddelande: Meddelande = {
|
||||
id: `user-${Date.now()}`,
|
||||
roll: 'user',
|
||||
innehall: input.trim(),
|
||||
tid: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMeddelanden(prev => [...prev, userMeddelande]);
|
||||
setInput('');
|
||||
setLaddar(true);
|
||||
|
||||
try {
|
||||
// Anropa Claude via OpenClaw API
|
||||
const response = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rum: context.rum,
|
||||
systemPrompt: context.systemPrompt,
|
||||
meddelanden: [...meddelanden, userMeddelande].map(m => ({
|
||||
roll: m.roll,
|
||||
innehall: m.innehall
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: data.svar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
} else {
|
||||
// Fallback: Mock-svar
|
||||
const mockSvar = genereraMockSvar(context, input.trim());
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: mockSvar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
const mockSvar = genereraMockSvar(context, input.trim());
|
||||
setMeddelanden(prev => [...prev, {
|
||||
id: `assistant-${Date.now()}`,
|
||||
roll: 'assistant',
|
||||
innehall: mockSvar,
|
||||
tid: new Date().toISOString()
|
||||
}]);
|
||||
}
|
||||
|
||||
setLaddar(false);
|
||||
};
|
||||
|
||||
const hanteraKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
skickaMeddelande();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-primary rounded-full shadow-lg flex items-center justify-center text-white hover:bg-primary/90 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Sparkles size={24} />
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop - click to close */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
className={`fixed right-0 top-0 h-full bg-surface border-l border-border z-50 flex flex-col shadow-xl ${
|
||||
isExpanded ? 'w-[500px]' : 'w-[380px]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-border flex items-center justify-between px-4 bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||
<Bot size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{context.titel}</h3>
|
||||
<p className="text-xs text-text-secondary">AI Assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-2 hover:bg-bg rounded-lg text-text-secondary"
|
||||
>
|
||||
{isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-bg rounded-lg text-text-secondary"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{meddelanden.map((meddelande) => (
|
||||
<div
|
||||
key={meddelande.id}
|
||||
className={`flex gap-3 ${meddelande.roll === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||
meddelande.roll === 'assistant'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-bg text-text-secondary'
|
||||
}`}>
|
||||
{meddelande.roll === 'assistant' ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm ${
|
||||
meddelande.roll === 'assistant'
|
||||
? 'bg-bg text-text-primary rounded-tl-none'
|
||||
: 'bg-primary text-white rounded-tr-none'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap">{meddelande.innehall}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{laddar && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
|
||||
<Bot size={16} />
|
||||
</div>
|
||||
<div className="bg-bg rounded-2xl rounded-tl-none px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce delay-100" />
|
||||
<div className="w-2 h-2 bg-text-secondary rounded-full animate-bounce delay-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={hanteraKeyDown}
|
||||
placeholder="Skriv ett meddelande..."
|
||||
className="flex-1 min-h-[44px] max-h-[120px] px-4 py-2.5 rounded-xl border bg-surface text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={skickaMeddelande}
|
||||
disabled={!input.trim() || laddar}
|
||||
className="w-11 h-11 bg-primary text-white rounded-xl flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-2 text-center">
|
||||
AI kan göra misstag. Verifiera viktig information.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function genereraMockSvar(context: AgentContext, input: string): string {
|
||||
const inputLower = input.toLowerCase();
|
||||
|
||||
if (context.rum === 'finance') {
|
||||
if (inputLower.includes('moms')) {
|
||||
return 'MOMS (mervärdesskatt) ska redovisas månadsvis eller kvartalsvis beroende på företagets omsättning. Nuvarande MOMS-att-betala är 336 006 kr för perioden. Vill du se detaljerad MOMS-rapport?';
|
||||
}
|
||||
if (inputLower.includes('faktura') || inputLower.includes('invoice')) {
|
||||
return 'Det finns för närvarande 3 fakturor som väntar på betalning. Faktura #INV-2024-0042 är 3 dagar försenad. Vill du skicka en påminnelse?';
|
||||
}
|
||||
if (inputLower.includes('balans')) {
|
||||
return 'Totala tillgångar är 1 842 278 kr, skulder 435 657 kr och eget kapital 1 406 621 kr. Soliditeten är 76.4% vilket är mycket starkt.';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med finansiell analys, MOMS-rapportering, fakturahantering och kassaflödesprognoser. Vad vill du veta mer om?';
|
||||
}
|
||||
|
||||
if (context.rum === 'sales') {
|
||||
if (inputLower.includes('lead')) {
|
||||
return 'Just nu har vi 12 aktiva leads i pipelinen. 3 är i förhandlingsfas och beräknas stängas denna månad. Vill du se detaljerad pipeline?';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med lead-hantering, offertförfrågningar och säljrapporter. Vad behöver du hjälp med?';
|
||||
}
|
||||
|
||||
if (context.rum === 'hr') {
|
||||
if (inputLower.includes('semester')) {
|
||||
return 'Du har 25 semesterdagar kvar att ta ut i år. Nästa planerade semester är vecka 32. Vill du ansöka om ny semester?';
|
||||
}
|
||||
return 'Jag kan hjälpa dig med HR-frågor, semesterplanering och personalärenden. Vad kan jag göra för dig?';
|
||||
}
|
||||
|
||||
return `Jag förstår. Som ${context.titel} kan jag hjälpa dig med ${context.kompetenser.join(', ')}. Kan du specificera vad du behöver hjälp med?`;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export interface AgentContext {
|
||||
rum: string; // 'finance', 'sales', 'hr', etc.
|
||||
titel: string;
|
||||
systemPrompt: string;
|
||||
kompetenser: string[];
|
||||
dataTyp: string; // Vilken typ av data agenten har tillgång till
|
||||
}
|
||||
|
||||
export const agentContexts: Record<string, AgentContext> = {
|
||||
finance: {
|
||||
rum: 'finance',
|
||||
titel: 'Finance AI',
|
||||
systemPrompt: `Du är en expert på finans och redovisning. Du hjälper användaren med:
|
||||
- Analys av balansräkning och resultaträkning
|
||||
- MOMS-rapportering och skattefrågor
|
||||
- Fakturahantering och betalningspåminnelser
|
||||
- Kassaflödesanalys och prognoser
|
||||
- Bokslut och årsredovisning
|
||||
|
||||
Du har tillgång till företagets finansiella data i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var professionell, noggrann och hjälpsam.`,
|
||||
kompetenser: ['redovisning', 'finansanalys', 'skatt', 'fakturering'],
|
||||
dataTyp: 'financial'
|
||||
},
|
||||
sales: {
|
||||
rum: 'sales',
|
||||
titel: 'Sales AI',
|
||||
systemPrompt: `Du är en expert på försäljning och CRM. Du hjälper användaren med:
|
||||
- Lead-hantering och kvalificering
|
||||
- Offertförfrågningar och prissättning
|
||||
- Säljrapporter och pipeline-analys
|
||||
- Kundkommunikation och uppföljning
|
||||
- Säljstrategi och marknadsanalys
|
||||
|
||||
Du har tillgång till CRM-data och säljstatistik i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.`,
|
||||
kompetenser: ['försäljning', 'CRM', 'leads', 'offert'],
|
||||
dataTyp: 'sales'
|
||||
},
|
||||
hr: {
|
||||
rum: 'hr',
|
||||
titel: 'HR AI',
|
||||
systemPrompt: `Du är en expert på HR och personalfrågor. Du hjälper användaren med:
|
||||
- Rekrytering och anställningsprocesser
|
||||
- Personalhandbok och policyer
|
||||
- Lönehantering och förmåner
|
||||
- Semesterplanering och frånvaro
|
||||
- Medarbetarsamtal och utveckling
|
||||
|
||||
Du har tillgång till personaldata och HR-statistik.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var empatisk, professionell och diskret.`,
|
||||
kompetenser: ['HR', 'rekrytering', 'lön', 'personal'],
|
||||
dataTyp: 'hr'
|
||||
},
|
||||
crm: {
|
||||
rum: 'crm',
|
||||
titel: 'CRM AI',
|
||||
systemPrompt: `Du är en expert på kundrelationer och CRM. Du hjälper användaren med:
|
||||
- Kundanalys och segmentering
|
||||
- Kundresor och touchpoints
|
||||
- Supportärenden och eskalering
|
||||
- Kundnöjdhet och NPS
|
||||
- Kundhistorik och interaktioner
|
||||
|
||||
Du har tillgång till CRM-data och kundinteraktioner i realtid.
|
||||
Svara på svenska eller engelska beroende på användarens språk.`,
|
||||
kompetenser: ['CRM', 'kundservice', 'support', 'analys'],
|
||||
dataTyp: 'crm'
|
||||
},
|
||||
legal: {
|
||||
rum: 'legal',
|
||||
titel: 'Legal AI',
|
||||
systemPrompt: `Du är en expert på juridik och compliance. Du hjälper användaren med:
|
||||
- Avtalsgranskning och tolkning
|
||||
- GDPR och dataskydd
|
||||
- Företagsjuridik och bolagsstyrning
|
||||
- Immaterialrätt och licenser
|
||||
- Regelverk och efterlevnad
|
||||
|
||||
OBS: Du ersätter inte en advokat. Vid komplexa juridiska frågor, hänvisa alltid till jurist.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var noggrann, försiktig och tydlig med begränsningar.`,
|
||||
kompetenser: ['juridik', 'GDPR', 'avtal', 'compliance'],
|
||||
dataTyp: 'legal'
|
||||
},
|
||||
marketing: {
|
||||
rum: 'marketing',
|
||||
titel: 'Marketing AI',
|
||||
systemPrompt: `Du är en expert på marknadsföring och kommunikation. Du hjälper användaren med:
|
||||
- Kampanjplanering och analys
|
||||
- Sociala medier och content
|
||||
- SEO och digital marknadsföring
|
||||
- Marknadsanalys och konkurrenter
|
||||
- Varumärke och positionering
|
||||
|
||||
Du har tillgång till marknadsdata och kampanjstatistik.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var kreativ, strategisk och datadriven.`,
|
||||
kompetenser: ['marknadsföring', 'SEO', 'sociala medier', 'analys'],
|
||||
dataTyp: 'marketing'
|
||||
},
|
||||
dashboard: {
|
||||
rum: 'dashboard',
|
||||
titel: 'AMOS Assistant',
|
||||
systemPrompt: `Du är AMOS Assistant - en generell AI-assistent för AAMOS-plattformen.
|
||||
Du hjälper användaren med:
|
||||
- Översikt och navigering i systemet
|
||||
- Tekniska frågor om AAMOS-produkter
|
||||
- Integrationer och API:er
|
||||
- Felsökning och support
|
||||
- Allmänna frågor om Landvex och quiXzoom
|
||||
|
||||
Du har bred kunskap om hela plattformen.
|
||||
Svara på svenska eller engelska beroende på användarens språk.
|
||||
Var hjälpsam, kunnig och effektiv.`,
|
||||
kompetenser: ['generell', 'support', 'teknik', 'navigering'],
|
||||
dataTyp: 'general'
|
||||
}
|
||||
};
|
||||
|
||||
export function getAgentContext(rum: string): AgentContext {
|
||||
return agentContexts[rum] || agentContexts.dashboard;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @ts-nocheck
|
||||
import { BankAccount } from '@/types/bank';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { RefreshCw, Link2, Link2Off } from 'lucide-react';
|
||||
|
||||
interface BankAccountCardProps {
|
||||
account: BankAccount;
|
||||
onSync: (id: string) => void;
|
||||
onConnect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function BankAccountCard({ account, onSync, onConnect }: BankAccountCardProps) {
|
||||
const bankColors = {
|
||||
revolut: 'bg-blue-50 border-blue-200',
|
||||
nordea: 'bg-red-50 border-red-200',
|
||||
other: 'bg-gray-50 border-gray-200',
|
||||
};
|
||||
|
||||
const bankIcons = {
|
||||
revolut: '🔵',
|
||||
nordea: '🔴',
|
||||
other: '⚪',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-5 ${bankColors[account.bank]} border`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center text-xl shadow-sm">
|
||||
{bankIcons[account.bank]}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{account.name}</h3>
|
||||
<p className="text-sm text-gray-500">{account.accountNumber}</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">IBAN: {account.iban}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{account.apiConnected ? (
|
||||
<button
|
||||
onClick={() => onSync(account.id)}
|
||||
className="p-2 rounded-lg bg-white hover:bg-gray-50 text-green-600 transition-colors"
|
||||
title="Synka nu"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onConnect(account.id)}
|
||||
className="p-2 rounded-lg bg-white hover:bg-gray-50 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Koppla API"
|
||||
>
|
||||
<Link2Off size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200/60">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-gray-500">Saldo</span>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{account.balance.toLocaleString('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{account.lastSync && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Senast synkad: {new Date(account.lastSync).toLocaleString('sv-SE')}
|
||||
</p>
|
||||
)}
|
||||
{account.apiConnected && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 mt-2">
|
||||
<Link2 size={12} />
|
||||
API kopplad
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useRef } from 'react';
|
||||
import { Upload, FileSpreadsheet, FileText, X } from 'lucide-react';
|
||||
|
||||
interface StatementUploadProps {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
onUpload: (file: File, accountId: string) => void;
|
||||
}
|
||||
|
||||
export function StatementUpload({ accountId, accountName, onUpload }: StatementUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (selectedFile) {
|
||||
onUpload(selectedFile, accountId);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string) => {
|
||||
if (filename.endsWith('.csv')) return <FileSpreadsheet size={24} className="text-green-600" />;
|
||||
if (filename.endsWith('.pdf')) return <FileText size={24} className="text-red-600" />;
|
||||
return <FileText size={24} className="text-gray-600" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">Ladda upp kontoutdrag - {accountName}</h4>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Upload size={32} className="mx-auto text-gray-400 mb-3" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Dra och släpp fil här, eller <span className="text-blue-600">klicka för att välja</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Stödjer CSV, PDF, XLSX
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.pdf,.xlsx"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getFileIcon(selectedFile.name)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{selectedFile.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedFile(null)}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-400"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Importera kontoutrag
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// @ts-nocheck
|
||||
import { BankTransaction } from '@/types/bank';
|
||||
import { ArrowDownLeft, ArrowUpRight, FileText } from 'lucide-react';
|
||||
|
||||
interface TransactionListProps {
|
||||
transactions: BankTransaction[];
|
||||
onMatch?: (txId: string) => void;
|
||||
}
|
||||
|
||||
export function TransactionList({ transactions, onMatch }: TransactionListProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-white border border-gray-100 hover:border-gray-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
tx.type === 'credit' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
{tx.type === 'credit' ? <ArrowDownLeft size={16} /> : <ArrowUpRight size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{tx.description}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{tx.date}</span>
|
||||
{tx.counterparty && <span>• {tx.counterparty}</span>}
|
||||
{tx.category && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100">{tx.category}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-semibold ${
|
||||
tx.type === 'credit' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{tx.type === 'credit' ? '+' : '-'}
|
||||
{Math.abs(tx.amount).toLocaleString('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: tx.currency,
|
||||
})}
|
||||
</p>
|
||||
{tx.matchedJournalEntryId ? (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1 justify-end mt-1">
|
||||
<FileText size={10} />
|
||||
Bokförd
|
||||
</span>
|
||||
) : onMatch && (
|
||||
<button
|
||||
onClick={() => onMatch(tx.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 mt-1"
|
||||
>
|
||||
Matcha med verifikat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface MarketingStats {
|
||||
active_campaigns: number;
|
||||
scheduled_posts: number;
|
||||
impressions: number;
|
||||
engagement: number;
|
||||
}
|
||||
|
||||
export function MarketingWidget() {
|
||||
const [stats, setStats] = useState<MarketingStats>({
|
||||
active_campaigns: 0,
|
||||
scheduled_posts: 0,
|
||||
impressions: 0,
|
||||
engagement: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Mock data for now
|
||||
setTimeout(() => {
|
||||
setStats({
|
||||
active_campaigns: 3,
|
||||
scheduled_posts: 12,
|
||||
impressions: 45200,
|
||||
engagement: 2340
|
||||
});
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-pink-100 rounded-lg flex items-center justify-center">
|
||||
<Megaphone size={16} className="text-pink-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Marketing</h3>
|
||||
</div>
|
||||
<Link to="/marketing" className="text-sm text-pink-600 hover:text-pink-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="bg-pink-50 rounded-lg p-3">
|
||||
<div className="text-xs text-pink-600 mb-1">Active Campaigns</div>
|
||||
<div className="text-2xl font-bold text-pink-700">{stats.active_campaigns}</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<div className="text-xs text-purple-600 mb-1">Scheduled</div>
|
||||
<div className="text-2xl font-bold text-purple-700">{stats.scheduled_posts}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<Eye size={14} /> Impressions
|
||||
</span>
|
||||
<span className="font-medium">{stats.impressions.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<TrendingUp size={14} /> Engagement
|
||||
</span>
|
||||
<span className="font-medium">{stats.engagement.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FolderKanban, TrendingUp, CheckCircle2, AlertCircle, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface ProjectStats {
|
||||
active: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export function ProjectWidget() {
|
||||
const [stats, setStats] = useState<ProjectStats>({ active: 0, completed: 0, overdue: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/dashboard/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats({
|
||||
active: data.active_issues || 0,
|
||||
completed: 12,
|
||||
overdue: 2
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setStats({ active: 8, completed: 12, overdue: 2 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<FolderKanban size={16} className="text-purple-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Projects</h3>
|
||||
</div>
|
||||
<Link to="/projects" className="text-sm text-purple-600 hover:text-purple-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-blue-600" />
|
||||
<span className="text-sm text-blue-700">Active</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-blue-700">{stats.active}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 size={16} className="text-green-600" />
|
||||
<span className="text-sm text-green-700">Completed</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-green-700">{stats.completed}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-red-600" />
|
||||
<span className="text-sm text-red-700">Overdue</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-red-700">{stats.overdue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SLAViolation {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
subject: string;
|
||||
violation_type: string;
|
||||
severity: string;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
export function SLAWidget() {
|
||||
const [violations, setViolations] = useState<SLAViolation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/sla/violations')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setViolations(data.slice(0, 3));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setViolations([
|
||||
{ id: '1', ticket_number: 'SUP-2024-005', subject: 'Database timeout', violation_type: 'sla_exceeded', severity: 'critical', detected_at: new Date().toISOString() },
|
||||
{ id: '2', ticket_number: 'SUP-2024-003', subject: 'Invoice not received', violation_type: 'no_response', severity: 'high', detected_at: new Date().toISOString() },
|
||||
]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Shield size={16} className="text-yellow-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">SLA Status</h3>
|
||||
</div>
|
||||
{violations.length > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||
{violations.length} violations
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{violations.length === 0 ? (
|
||||
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
|
||||
<CheckCircle2 size={20} className="text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700">All SLA met</p>
|
||||
<p className="text-xs text-green-600">No violations detected</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{violations.map(v => (
|
||||
<div key={v.id} className={`p-3 rounded-lg ${
|
||||
v.severity === 'critical' ? 'bg-red-50' :
|
||||
v.severity === 'high' ? 'bg-orange-50' :
|
||||
'bg-yellow-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle size={14} className={
|
||||
v.severity === 'critical' ? 'text-red-600' :
|
||||
v.severity === 'high' ? 'text-orange-600' :
|
||||
'text-yellow-600'
|
||||
} />
|
||||
<span className="text-sm font-medium">{v.ticket_number}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">{v.subject}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{v.violation_type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Server, CheckCircle2, AlertCircle, XCircle, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Service {
|
||||
service_name: string;
|
||||
status: string;
|
||||
last_check: string;
|
||||
consecutive_failures: number;
|
||||
}
|
||||
|
||||
export function ServiceHealthWidget() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/services/health')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setServices(data.slice(0, 5));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setServices([
|
||||
{ service_name: 'aamos-ledger', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'aamos-identity', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'api-gateway', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'postgres-primary', status: 'up', last_check: new Date().toISOString(), consecutive_failures: 0 },
|
||||
{ service_name: 'redis-cache', status: 'down', last_check: new Date().toISOString(), consecutive_failures: 2 },
|
||||
]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const downServices = services.filter(s => s.status === 'down' || s.status === 'error');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Server size={16} className="text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Service Health</h3>
|
||||
</div>
|
||||
{downServices.length > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||
{downServices.length} down
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{services.map(service => (
|
||||
<div key={service.service_name} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
{service.status === 'up' ? (
|
||||
<CheckCircle2 size={14} className="text-green-500" />
|
||||
) : service.status === 'down' ? (
|
||||
<XCircle size={14} className="text-red-500" />
|
||||
) : (
|
||||
<AlertCircle size={14} className="text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm">{service.service_name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
service.status === 'up' ? 'bg-green-100 text-green-700' :
|
||||
service.status === 'down' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{service.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle, Clock, CheckCircle2, Headphones, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface TicketStats {
|
||||
total: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
export function TicketWidget() {
|
||||
const [stats, setStats] = useState<TicketStats>({ total: 0, open: 0, in_progress: 0, resolved: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3457/api/tickets/stats')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback mock data
|
||||
setStats({ total: 24, open: 8, in_progress: 5, resolved: 11 });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white border rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Headphones size={16} className="text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Support Tickets</h3>
|
||||
</div>
|
||||
<Link to="/support" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-orange-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle size={14} className="text-orange-600" />
|
||||
<span className="text-xs text-orange-600 font-medium">Open</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-orange-700">{stats.open}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock size={14} className="text-blue-600" />
|
||||
<span className="text-xs text-blue-600 font-medium">In Progress</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-700">{stats.in_progress}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 size={14} className="text-green-600" />
|
||||
<span className="text-xs text-green-600 font-medium">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">{stats.resolved}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Headphones size={14} className="text-gray-600" />
|
||||
<span className="text-xs text-gray-600 font-medium">Total</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-700">{stats.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Shield,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
Share2,
|
||||
Bot,
|
||||
Briefcase,
|
||||
FileText,
|
||||
Megaphone,
|
||||
HeadphonesIcon,
|
||||
Newspaper,
|
||||
Cpu,
|
||||
Crown,
|
||||
Zap,
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: 'Översikt',
|
||||
items: [
|
||||
{ path: '/', label: 'Briefing', icon: Newspaper },
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/mail', label: 'Mail', icon: Mail },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Produkter',
|
||||
items: [
|
||||
{ path: '/alva', label: 'Alva', icon: Bot },
|
||||
{ path: '/amos', label: 'AMOS', icon: Cpu },
|
||||
{ path: '/quixzoom', label: 'quiXzoom', icon: Globe },
|
||||
{ path: '/landvex', label: 'Landvex', icon: Crown },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Verksamhet',
|
||||
items: [
|
||||
{ path: '/crm', label: 'CRM', icon: Users },
|
||||
{ path: '/sales', label: 'Sales', icon: TrendingUp },
|
||||
{ path: '/marketing', label: 'Marketing', icon: Megaphone },
|
||||
{ path: '/social', label: 'Social', icon: Share2 },
|
||||
{ path: '/finance', label: 'Finance', icon: Wallet },
|
||||
{ path: '/accounting', label: 'Accounting', icon: Briefcase },
|
||||
{ path: '/hr', label: 'HR', icon: Briefcase },
|
||||
{ path: '/legal', label: 'Legal', icon: FileText },
|
||||
{ path: '/compliance', label: 'Compliance', icon: Shield },
|
||||
{ path: '/support', label: 'Support', icon: HeadphonesIcon },
|
||||
{ path: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ path: '/automation', label: 'Automation', icon: Zap },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function MobileNav() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
'Översikt': true,
|
||||
'Produkter': true,
|
||||
'Verksamhet': true,
|
||||
})
|
||||
const location = useLocation()
|
||||
const { logout, isAuthenticated } = useAuthStore()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
const toggleGroup = (label: string) => {
|
||||
setOpenGroups(prev => ({ ...prev, [label]: !prev[label] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top header */}
|
||||
<div className="fixed top-0 left-0 right-0 z-40 bg-bg border-b border-border-subtle px-4 py-3 flex items-center justify-between lg:hidden">
|
||||
<Link to="/" className="text-lg font-bold text-text-primary tracking-wide">
|
||||
BOC
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="p-2 rounded-lg bg-surface text-text-primary active:bg-surface-hover"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Full screen overlay */}
|
||||
{menuOpen && (
|
||||
<div className="fixed inset-0 z-[100] bg-bg lg:hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle bg-bg shrink-0">
|
||||
<span className="text-lg font-bold text-text-primary">Meny</span>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="p-2 rounded-lg bg-surface text-text-primary active:bg-surface-hover border border-border/40"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav groups */}
|
||||
<div className="p-4 space-y-2 overflow-y-auto flex-1">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
className="flex items-center justify-between w-full px-4 py-2.5 rounded-xl text-sm font-semibold uppercase tracking-wider text-text-tertiary hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{group.label}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn('transition-transform', !openGroups[group.label] && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{openGroups[group.label] && (
|
||||
<div className="ml-2 space-y-0.5 mt-1">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium transition-colors',
|
||||
isActive(item.path)
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-text-secondary hover:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={() => { logout(); setMenuOpen(false); window.location.href = '/login' }}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-base font-medium text-danger hover:bg-danger-light transition-colors w-full mt-4"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
Logga ut
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { X, Send, Paperclip, Sparkles, Loader2 } from 'lucide-react'
|
||||
|
||||
interface ComposeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
replyTo?: {
|
||||
uid: number
|
||||
subject: string
|
||||
from: string
|
||||
body: string
|
||||
}
|
||||
onSent?: () => void
|
||||
}
|
||||
|
||||
export function ComposeModal({ isOpen, onClose, replyTo, onSent }: ComposeModalProps) {
|
||||
const [to, setTo] = useState(replyTo ? extractEmail(replyTo.from) : '')
|
||||
const [subject, setSubject] = useState(replyTo ? `Re: ${replyTo.subject.replace(/^Re: /i, '')}` : '')
|
||||
const [body, setBody] = useState(replyTo ? `\n\n---\n${replyTo.body.substring(0, 500)}` : '')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<File[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
function extractEmail(from: string): string {
|
||||
const match = from.match(/<([^>]+)>/)
|
||||
return match ? match[1] : from
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!to || !subject || !body) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const token = localStorage.getItem('amos_token')
|
||||
const res = await fetch('/api/v1/mail/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: [to],
|
||||
subject,
|
||||
body,
|
||||
reply_to: replyTo?.from,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
setTo('')
|
||||
setSubject('')
|
||||
setBody('')
|
||||
setAttachments([])
|
||||
onSent?.()
|
||||
onClose()
|
||||
} else {
|
||||
alert(data.error || 'Failed to send')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to send email')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAIAssist() {
|
||||
if (!body.trim()) return
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const token = localStorage.getItem('amos_token')
|
||||
const res = await fetch('/api/v1/mail/ai-assist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
context: body,
|
||||
tone: 'professional',
|
||||
language: 'sv',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.ok && data.improved) {
|
||||
setBody(data.improved)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI assist failed:', err)
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files) {
|
||||
setAttachments(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{replyTo ? 'Svara' : 'Nytt meddelande'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-bg text-text-secondary">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Till</label>
|
||||
<input
|
||||
type="email"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Ämne</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="Ämne"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Meddelande</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 rounded-xl border bg-surface text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
|
||||
placeholder="Skriv ditt meddelande..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-text-secondary">Bilagor</p>
|
||||
{attachments.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-2 bg-bg rounded-lg">
|
||||
<Paperclip size={16} className="text-text-secondary" />
|
||||
<span className="text-sm text-text-primary">{file.name}</span>
|
||||
<span className="text-xs text-text-secondary">({(file.size / 1024).toFixed(0)} KB)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Paperclip size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Bifoga
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={aiLoading ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
onClick={handleAIAssist}
|
||||
disabled={aiLoading || !body.trim()}
|
||||
>
|
||||
AI-hjälp
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon={sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
onClick={handleSend}
|
||||
disabled={sending || !to || !subject || !body}
|
||||
>
|
||||
{sending ? 'Skickar...' : 'Skicka'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DarkModeToggle() {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const saved = localStorage.getItem('amos-dark-mode')
|
||||
const isDark = saved ? saved === 'true' : prefersDark
|
||||
setDarkMode(isDark)
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const newMode = !darkMode
|
||||
setDarkMode(newMode)
|
||||
localStorage.setItem('amos-dark-mode', String(newMode))
|
||||
|
||||
if (newMode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors',
|
||||
darkMode
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-bg text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface GestureNavProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function GestureNav({ children }: GestureNavProps) {
|
||||
const navigate = useNavigate()
|
||||
const lastTap = useRef<number>(0)
|
||||
const tapCount = useRef<number>(0)
|
||||
const tapTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleTap = useCallback((_e: React.TouchEvent) => {
|
||||
const now = Date.now()
|
||||
const timeDiff = now - lastTap.current
|
||||
|
||||
if (timeDiff < 300) {
|
||||
// Double tap detected
|
||||
tapCount.current += 1
|
||||
|
||||
if (tapCount.current === 2) {
|
||||
// Triple tap - go to dashboard
|
||||
Haptics.medium()
|
||||
navigate('/dashboard')
|
||||
tapCount.current = 0
|
||||
}
|
||||
} else {
|
||||
tapCount.current = 1
|
||||
}
|
||||
|
||||
lastTap.current = now
|
||||
|
||||
// Reset tap count after delay
|
||||
if (tapTimer.current) {
|
||||
clearTimeout(tapTimer.current)
|
||||
}
|
||||
tapTimer.current = setTimeout(() => {
|
||||
tapCount.current = 0
|
||||
}, 500)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div onTouchEnd={handleTap}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MobileCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function MobileCard({ children, className, onClick }: MobileCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
onClick && 'active:scale-[0.98] transition-transform cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardRowProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardRow({ label, value, className }: MobileCardRowProps) {
|
||||
return (
|
||||
<div className={cn('flex justify-between items-center py-2', className)}>
|
||||
<span className="text-sm text-text-secondary">{label}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileCardBadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileCardBadge({ children, variant = 'default', className }: MobileCardBadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-bg text-text-secondary',
|
||||
success: 'bg-success-light text-success',
|
||||
warning: 'bg-warning-light text-warning',
|
||||
danger: 'bg-danger-light text-danger',
|
||||
info: 'bg-primary-light text-primary',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('text-xs font-medium px-2.5 py-1 rounded-full', variants[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface MobileChartProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: number
|
||||
changeLabel?: string
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger'
|
||||
sparklineData?: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MobileChart({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changeLabel,
|
||||
color = 'primary',
|
||||
sparklineData,
|
||||
className,
|
||||
}: MobileChartProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const colors = {
|
||||
primary: 'text-primary bg-primary-light',
|
||||
success: 'text-success bg-success-light',
|
||||
warning: 'text-warning bg-warning-light',
|
||||
danger: 'text-danger bg-danger-light',
|
||||
}
|
||||
|
||||
const sparklineColor = {
|
||||
primary: '#2563EB',
|
||||
success: '#16A34A',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
}
|
||||
|
||||
// Simple SVG sparkline
|
||||
const renderSparkline = () => {
|
||||
if (!sparklineData || sparklineData.length < 2) return null
|
||||
|
||||
const width = 120
|
||||
const height = 40
|
||||
const max = Math.max(...sparklineData)
|
||||
const min = Math.min(...sparklineData)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = sparklineData.map((v, i) => {
|
||||
const x = (i / (sparklineData.length - 1)) * width
|
||||
const y = height - ((v - min) / range) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="mt-2">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={sparklineColor[color]}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface rounded-2xl p-4 card-shadow border border-border/40',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary uppercase tracking-wider">{title}</p>
|
||||
<p className="text-2xl font-semibold text-text-primary mt-1">{value}</p>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn('text-xs font-medium', change >= 0 ? 'text-success' : 'text-danger')}>
|
||||
{change >= 0 ? '+' : ''}{change}%
|
||||
</span>
|
||||
{changeLabel && (
|
||||
<span className="text-xs text-text-secondary">{changeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
|
||||
colors[color]
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && sparklineData && (
|
||||
<div className="mt-3 pt-3 border-t border-border/40">
|
||||
{renderSparkline()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface PullToRefreshProps {
|
||||
onRefresh: () => Promise<void>
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const touchStartY = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const maxPullDistance = 100
|
||||
const refreshThreshold = 80
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// Only allow pull-to-refresh when at top of scroll
|
||||
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||||
touchStartY.current = e.targetTouches[0].clientY
|
||||
setPulling(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!pulling) return
|
||||
|
||||
const currentY = e.targetTouches[0].clientY
|
||||
const diff = currentY - touchStartY.current
|
||||
|
||||
if (diff > 0) {
|
||||
// Resistance increases as user pulls further
|
||||
const resistance = 1 + (diff / maxPullDistance) * 0.5
|
||||
const newDistance = Math.min(diff / resistance, maxPullDistance)
|
||||
setPullDistance(newDistance)
|
||||
}
|
||||
}, [pulling])
|
||||
|
||||
const onTouchEnd = useCallback(async () => {
|
||||
if (!pulling) return
|
||||
|
||||
if (pullDistance >= refreshThreshold && !refreshing) {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefresh()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
setPulling(false)
|
||||
setPullDistance(0)
|
||||
}, [pulling, pullDistance, refreshing, onRefresh])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative overflow-y-auto', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Pull indicator */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 flex items-center justify-center transition-transform"
|
||||
style={{
|
||||
transform: `translateY(${pullDistance - 60}px)`,
|
||||
opacity: pulling ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw
|
||||
size={24}
|
||||
className={cn(
|
||||
'text-primary transition-transform',
|
||||
refreshing && 'animate-spin',
|
||||
!refreshing && pullDistance >= refreshThreshold && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{refreshing ? 'Refreshing...' : pullDistance >= refreshThreshold ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content with offset when pulling */}
|
||||
<div
|
||||
style={{
|
||||
transform: pulling ? `translateY(${pullDistance}px)` : 'translateY(0)',
|
||||
transition: pulling ? 'none' : 'transform 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { MobileCard, MobileCardRow } from './MobileCard'
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (item: T) => React.ReactNode
|
||||
mobile?: boolean // show on mobile?
|
||||
}
|
||||
|
||||
interface ResponsiveTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
onRowClick?: (item: T) => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
export function ResponsiveTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
title,
|
||||
subtitle,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data',
|
||||
}: ResponsiveTableProps<T>) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const newSet = new Set(expandedRows)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
setExpandedRows(newSet)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text-secondary">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
|
||||
<table className="w-full">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="py-3 px-4 text-xs font-medium text-text-secondary uppercase tracking-wider text-left"
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors hover:bg-bg/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="py-3.5 px-4 text-sm text-text-primary">
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{title && <h3 className="text-lg font-semibold mb-2">{title}</h3>}
|
||||
{subtitle && <p className="text-sm text-text-secondary mb-4">{subtitle}</p>}
|
||||
{data.map((item) => {
|
||||
const id = keyExtractor(item)
|
||||
const isExpanded = expandedRows.has(id)
|
||||
const mobileColumns = columns.filter((c) => c.mobile !== false)
|
||||
const primaryCol = mobileColumns[0]
|
||||
const secondaryCols = mobileColumns.slice(1)
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={id}
|
||||
onClick={() => {
|
||||
if (secondaryCols.length > 2) {
|
||||
toggleRow(id)
|
||||
} else {
|
||||
onRowClick?.(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">
|
||||
{primaryCol?.render(item)}
|
||||
</div>
|
||||
{secondaryCols.length <= 2 && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{secondaryCols.slice(0, 2).map((col) => (
|
||||
<span key={col.key} className="text-xs text-text-secondary">
|
||||
{col.header}: {col.render(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{secondaryCols.length > 2 && (
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
'text-text-secondary transition-transform',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && secondaryCols.length > 2 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50 space-y-1">
|
||||
{secondaryCols.map((col) => (
|
||||
<MobileCardRow
|
||||
key={col.key}
|
||||
label={col.header}
|
||||
value={col.render(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MobileCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Haptics } from '@/lib/haptics'
|
||||
|
||||
interface SwipeContainerProps {
|
||||
children: React.ReactNode
|
||||
onSwipeLeft?: () => void
|
||||
onSwipeRight?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SwipeContainer({ children, onSwipeLeft, onSwipeRight, className }: SwipeContainerProps) {
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
const touchEnd = useRef<{ x: number; y: number } | null>(null)
|
||||
const [swiping, setSwiping] = useState(false)
|
||||
|
||||
const minSwipeDistance = 50
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = null
|
||||
touchStart.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
setSwiping(true)
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = { x: e.targetTouches[0].clientX, y: e.targetTouches[0].clientY }
|
||||
}, [])
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
setSwiping(false)
|
||||
if (!touchStart.current || !touchEnd.current) return
|
||||
|
||||
const distanceX = touchStart.current.x - touchEnd.current.x
|
||||
const distanceY = touchStart.current.y - touchEnd.current.y
|
||||
const isHorizontalSwipe = Math.abs(distanceX) > Math.abs(distanceY)
|
||||
|
||||
if (isHorizontalSwipe && Math.abs(distanceX) > minSwipeDistance) {
|
||||
Haptics.swipe()
|
||||
if (distanceX > 0) {
|
||||
onSwipeLeft?.()
|
||||
} else {
|
||||
onSwipeRight?.()
|
||||
}
|
||||
}
|
||||
|
||||
touchStart.current = null
|
||||
touchEnd.current = null
|
||||
}, [onSwipeLeft, onSwipeRight])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('touch-pan-y', swiping && 'select-none', className)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user