Initial commit - ERP System
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard, Users, Ticket, LogOut, Shield, ShieldCheck, FileText, Settings,
|
||||
UserCircle, Search, Key, Menu, X, ChevronRight, Building2
|
||||
} from "lucide-react";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// STATUS TRANSLATION HELPER
|
||||
// ──────────────────────────────────────────────
|
||||
export const statusLabels: Record<string, string> = {
|
||||
OPEN: 'Offen',
|
||||
IN_PROGRESS: 'In Bearbeitung',
|
||||
WAITING_FOR_CUSTOMER: 'Wartet auf Kunde',
|
||||
RESOLVED: 'Gelöst',
|
||||
CLOSED: 'Geschlossen',
|
||||
};
|
||||
|
||||
export const priorityLabels: Record<string, string> = {
|
||||
LOW: 'Niedrig',
|
||||
MEDIUM: 'Mittel',
|
||||
HIGH: 'Hoch',
|
||||
CRITICAL: 'Kritisch',
|
||||
};
|
||||
|
||||
export const priorityColors: Record<string, string> = {
|
||||
LOW: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
MEDIUM: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
HIGH: 'bg-orange-50 text-orange-600 border-orange-200',
|
||||
CRITICAL: 'bg-red-50 text-red-700 border-red-200',
|
||||
};
|
||||
|
||||
export function getStatusBadge(status: string) {
|
||||
const label = statusLabels[status] || status;
|
||||
const colors: Record<string, string> = {
|
||||
OPEN: 'text-red-700 bg-red-50 border-red-200',
|
||||
IN_PROGRESS: 'text-amber-700 bg-amber-50 border-amber-200',
|
||||
WAITING_FOR_CUSTOMER: 'text-indigo-700 bg-indigo-50 border-indigo-200',
|
||||
RESOLVED: 'text-emerald-700 bg-emerald-50 border-emerald-200',
|
||||
CLOSED: 'text-slate-700 bg-slate-50 border-slate-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${colors[status] || colors.CLOSED}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriorityBadge(priority: string) {
|
||||
const label = priorityLabels[priority] || priority;
|
||||
const color = priorityColors[priority] || priorityColors.MEDIUM;
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// BREADCRUMBS
|
||||
// ──────────────────────────────────────────────
|
||||
const breadcrumbLabels: Record<string, string> = {
|
||||
'': 'Übersicht',
|
||||
customers: 'Kunden',
|
||||
tickets: 'Tickets',
|
||||
billing: 'Abrechnung',
|
||||
products: 'Produkte',
|
||||
sales: 'Verkauf',
|
||||
users: 'Team',
|
||||
roles: 'Rechte',
|
||||
settings: 'Einstellungen',
|
||||
search: 'Suche',
|
||||
new: 'Neu',
|
||||
};
|
||||
|
||||
function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const crumbs = segments.map((seg, idx) => {
|
||||
const href = '/' + segments.slice(0, idx + 1).join('/');
|
||||
const isLast = idx === segments.length - 1;
|
||||
const label = breadcrumbLabels[seg] || (seg.startsWith('[') ? seg : `#${seg}`);
|
||||
|
||||
return (
|
||||
<span key={href} className="flex items-center gap-1.5">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-300" />
|
||||
{isLast ? (
|
||||
<span className="text-slate-600 font-medium">{label}</span>
|
||||
) : (
|
||||
<a href={href} className="text-slate-400 hover:text-indigo-600 transition">{label}</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<a href="/" className="text-slate-400 hover:text-indigo-600 transition">Start</a>
|
||||
{crumbs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// FORCE PASSWORD CHANGE FORM (CUSTOMER)
|
||||
// ──────────────────────────────────────────────
|
||||
function ForcePasswordChangeForm() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const res = await fetch('/api/portal/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
signOut({ callbackUrl: '/login' });
|
||||
} else {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50 flex flex-col items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full border border-slate-200">
|
||||
<div className="w-14 h-14 bg-amber-100 text-amber-600 rounded-2xl flex items-center justify-center mb-5 mx-auto">
|
||||
<Key className="w-7 h-7" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-center text-slate-900 mb-2">Sicherheitshinweis</h2>
|
||||
<p className="text-sm text-slate-500 text-center mb-6">
|
||||
Aus Sicherheitsgründen musst du dein initiales Passwort ändern, bevor du das Portal nutzen kannst.
|
||||
</p>
|
||||
|
||||
{error && <div className="mb-4 p-3 bg-red-50 text-red-700 border border-red-200 rounded-lg text-sm">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Neues Passwort vergeben</label>
|
||||
<input type="password" required minLength={6}
|
||||
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none transition"
|
||||
value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-amber-600 text-white font-bold py-2.5 rounded-lg hover:bg-amber-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Speichert...' : 'Passwort ändern & neu einloggen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// MAIN APP SHELL (Sidebar + Header + Content)
|
||||
// ──────────────────────────────────────────────
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [liveResults, setLiveResults] = useState<any[]>([]);
|
||||
const [showLiveResults, setShowLiveResults] = useState(false);
|
||||
const [liveLoading, setLiveLoading] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setShowLiveResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Debounced live search
|
||||
const handleSearchInput = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 2) {
|
||||
setLiveResults([]);
|
||||
setShowLiveResults(false);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLiveLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/customers/search?q=${encodeURIComponent(value)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLiveResults(data);
|
||||
setShowLiveResults(true);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLiveLoading(false);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const isLoginPage = pathname === "/login";
|
||||
|
||||
if (isLoginPage) return <div className="bg-slate-50 h-screen">{children}</div>;
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-slate-500 font-medium animate-pulse">Lade System...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userType = (session?.user as any)?.userType;
|
||||
const permissions = (session?.user as any)?.permissions || [];
|
||||
const canManageTeam = permissions.includes('TEAM_MANAGE');
|
||||
const canManageSettings = permissions.includes('SYSTEM_SETTINGS');
|
||||
|
||||
// ── CUSTOMER PORTAL ──
|
||||
if (userType === 'CUSTOMER') {
|
||||
if ((session?.user as any)?.forcePasswordChange) {
|
||||
return <ForcePasswordChangeForm />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50 flex flex-col">
|
||||
<header className="h-16 bg-white/80 backdrop-blur-md border-b border-slate-200 flex items-center justify-between px-6 md:px-8 shadow-sm sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-indigo-700 rounded-lg flex items-center justify-center font-bold text-white text-sm shadow-sm">P</div>
|
||||
<span className="text-lg font-bold text-slate-800">Kundenportal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 md:gap-6">
|
||||
<div className="hidden sm:flex items-center gap-2 text-sm font-medium text-slate-600">
|
||||
<UserCircle className="w-5 h-5" />
|
||||
{(session?.user as any)?.firstName} {(session?.user as any)?.lastName}
|
||||
</div>
|
||||
<button onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="text-sm font-medium text-slate-500 hover:text-red-600 transition flex items-center gap-2">
|
||||
<LogOut className="w-4 h-4" /> <span className="hidden sm:inline">Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 w-full max-w-5xl mx-auto p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canPurchasing = permissions.includes('PURCHASING_MANAGE');
|
||||
const canSales = permissions.includes('SALES_MANAGE');
|
||||
|
||||
// ── TEAM LAYOUT ──
|
||||
const navCategories = [
|
||||
{
|
||||
title: 'Übersicht & CRM',
|
||||
items: [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Kunden', href: '/customers', icon: Users },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Service & Abrechnung',
|
||||
items: [
|
||||
{ name: 'Tickets', href: '/tickets', icon: Ticket },
|
||||
...(canManageTeam ? [{ name: 'Abrechnung', href: '/billing', icon: FileText }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Warenwirtschaft',
|
||||
items: [
|
||||
...(canPurchasing ? [{ name: 'Produkte', href: '/products', icon: Building2 }] : []),
|
||||
...(canSales ? [{ name: 'Verkauf', href: '/sales', icon: FileText }] : [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Administration',
|
||||
items: [
|
||||
...(canManageTeam ? [
|
||||
{ name: 'Team', href: '/users', icon: Shield },
|
||||
{ name: 'Rechte', href: '/roles', icon: ShieldCheck }
|
||||
] : []),
|
||||
...(canManageSettings ? [{ name: 'Einstellungen', href: '/settings', icon: Settings }] : [])
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-50">
|
||||
{/* Mobile Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-30 lg:hidden animate-fade-in"
|
||||
onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-slate-900 text-slate-300 flex flex-col border-r border-slate-800 shadow-2xl lg:shadow-none
|
||||
transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-lg shadow-lg flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-extrabold text-white tracking-tight text-lg">ERP Pro</span>
|
||||
</div>
|
||||
<button className="lg:hidden p-1 hover:bg-slate-800 rounded-lg transition" onClick={() => setSidebarOpen(false)}>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 px-4 py-6 space-y-6 overflow-y-auto">
|
||||
{navCategories.map((category) => {
|
||||
if (category.items.length === 0) return null;
|
||||
return (
|
||||
<div key={category.title} className="space-y-1">
|
||||
<h3 className="px-3 text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
|
||||
{category.title}
|
||||
</h3>
|
||||
{category.items.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<a key={item.name} href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-indigo-500/15 to-transparent text-indigo-400 font-semibold border-l-2 border-indigo-500'
|
||||
: 'border-l-2 border-transparent hover:bg-slate-800/50 hover:text-white hover:translate-x-1'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-indigo-400' : 'text-slate-400'}`} /> {item.name}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<div className="px-3 py-2 mb-2 text-xs text-slate-500 truncate">
|
||||
<UserCircle className="w-4 h-4 inline mr-1.5" />
|
||||
{(session?.user as any)?.firstName} {(session?.user as any)?.lastName}
|
||||
</div>
|
||||
<button onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-slate-400 hover:bg-red-500/10 hover:text-red-400 transition-all">
|
||||
<LogOut className="w-5 h-5" /> Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col h-screen overflow-hidden bg-slate-50/50">
|
||||
<header className="h-16 bg-white/70 backdrop-blur-xl border-b border-slate-200 flex items-center justify-between px-4 md:px-8 sticky top-0 z-10 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="lg:hidden p-2 hover:bg-slate-100 rounded-lg transition" onClick={() => setSidebarOpen(true)}>
|
||||
<Menu className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div ref={searchRef} className="relative hidden sm:block">
|
||||
<form onSubmit={handleSearch}>
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 z-10" />
|
||||
<input type="text" placeholder="Kunde suchen… (Enter = alle Module)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchInput(e.target.value)}
|
||||
onFocus={() => { if (liveResults.length > 0) setShowLiveResults(true); }}
|
||||
className="pl-9 pr-4 py-2 bg-slate-100/80 border border-slate-200 focus:bg-white focus:border-indigo-400 focus:ring-4 focus:ring-indigo-500/10 rounded-xl text-sm outline-none transition-all w-48 md:w-72 shadow-sm" />
|
||||
</form>
|
||||
|
||||
{/* Live Search Dropdown */}
|
||||
{showLiveResults && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden z-50 animate-fade-in-up">
|
||||
{liveLoading ? (
|
||||
<div className="px-4 py-3 text-sm text-slate-400 text-center">Suche...</div>
|
||||
) : liveResults.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-slate-400 text-center">Keine Kunden gefunden</div>
|
||||
) : (
|
||||
<ul className="max-h-72 overflow-y-auto divide-y divide-slate-50">
|
||||
{liveResults.map((c: any) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { router.push(`/customers/${c.id}`); setShowLiveResults(false); setSearchQuery(''); }}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-indigo-50/60 transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 bg-slate-100 rounded-lg flex-shrink-0">
|
||||
<Building2 className="w-4 h-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{c.companyName || `${c.firstName} ${c.lastName}`}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{c.email}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t border-slate-100 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { handleSearch({ preventDefault: () => {} } as any); setShowLiveResults(false); }}
|
||||
className="w-full text-xs font-semibold text-indigo-600 hover:text-indigo-700 text-center transition"
|
||||
>
|
||||
Alle Module durchsuchen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user